diff --git a/.changeset/backstage-changelog.js b/.changeset/backstage-changelog.js index ea8ca2300b..22038b348b 100644 --- a/.changeset/backstage-changelog.js +++ b/.changeset/backstage-changelog.js @@ -23,13 +23,13 @@ const { async function getDependencyReleaseLine(changesets, dependenciesUpdated) { if (dependenciesUpdated.length === 0) return ''; - const updatedDepenenciesList = dependenciesUpdated.map( + const updatedDependenciesList = dependenciesUpdated.map( dependency => ` - ${dependency.name}@${dependency.newVersion}`, ); // Return one `Updated dependencies` bullet instead of repeating for each changeset; this // sacrifices the commit shas for brevity. - return ['- Updated dependencies', ...updatedDepenenciesList].join('\n'); + return ['- Updated dependencies', ...updatedDependenciesList].join('\n'); } module.exports = { diff --git a/.changeset/big-cougars-glow.md b/.changeset/big-cougars-glow.md deleted file mode 100644 index d1630905db..0000000000 --- a/.changeset/big-cougars-glow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/dev-utils': patch ---- - -Add theme switcher to sidebar of dev app. diff --git a/.changeset/big-months-float.md b/.changeset/big-months-float.md deleted file mode 100644 index 9acb7002e7..0000000000 --- a/.changeset/big-months-float.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Remove the "View Api" icon in the AboutCard, as the information is misleading for some users and is -duplicated in the tabs above. diff --git a/.changeset/blue-bikes-explode.md b/.changeset/blue-bikes-explode.md deleted file mode 100644 index 1c10a08a46..0000000000 --- a/.changeset/blue-bikes-explode.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@backstage/config-loader': minor ---- - -Update `loadConfig` to return `LoadConfigResult` instead of an array of `AppConfig`. - -This function is primarily used internally by other config loaders like `loadBackendConfig` which means no changes are required for most users. - -If you use `loadConfig` directly you will need to update your usage from: - -```diff -- const appConfigs = await loadConfig(options) -+ const { appConfigs } = await loadConfig(options) -``` diff --git a/.changeset/cost-insights-two-crabs-evolve.md b/.changeset/cost-insights-two-crabs-evolve.md deleted file mode 100644 index 8a932ea59c..0000000000 --- a/.changeset/cost-insights-two-crabs-evolve.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cost-insights': patch ---- - -Fixed generation of sample data in the example Cost Insights client diff --git a/.changeset/curly-rings-report.md b/.changeset/curly-rings-report.md new file mode 100644 index 0000000000..554cd0d1a8 --- /dev/null +++ b/.changeset/curly-rings-report.md @@ -0,0 +1,7 @@ +--- +'@backstage/core-components': patch +'@backstage/create-app': patch +'@backstage/plugin-catalog': patch +--- + +Addressed some peer dependency warnings diff --git a/.changeset/early-cobras-explode.md b/.changeset/early-cobras-explode.md deleted file mode 100644 index 34b4cb0e98..0000000000 --- a/.changeset/early-cobras-explode.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/catalog-client': patch ---- - -Update to the right version of @backstage/errors diff --git a/.changeset/eight-months-agree.md b/.changeset/eight-months-agree.md deleted file mode 100644 index 0f117d8a40..0000000000 --- a/.changeset/eight-months-agree.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@backstage/plugin-gcp-projects': patch ---- - -UI updates to GCP-projects plugin - -Adds the following to the project list page: - -- pagination -- filtering -- sorting -- rows per page -- show/hide columns - -Makes breadcrumb a link back to project list for the project details and new project views. - -In project list page, updates New project button to use RouterLink instead of `href` to avoid login prompt. - -In project details view, links to project details and logs now work, clicking on these will open the project or logs in GCP in new tab. diff --git a/.changeset/empty-dots-attend.md b/.changeset/empty-dots-attend.md deleted file mode 100644 index 3903dfa22b..0000000000 --- a/.changeset/empty-dots-attend.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@backstage/catalog-model': patch -'@backstage/cli': patch -'@backstage/config-loader': patch -'@backstage/plugin-scaffolder': patch ---- - -Update the json-schema dependency version. diff --git a/.changeset/five-lies-care.md b/.changeset/five-lies-care.md new file mode 100644 index 0000000000..dab3f55d52 --- /dev/null +++ b/.changeset/five-lies-care.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Can specify allowedOwners to the RepoUrlPicker picker in a template definition diff --git a/.changeset/ninety-spies-prove.md b/.changeset/fluffy-countries-knock.md similarity index 50% rename from .changeset/ninety-spies-prove.md rename to .changeset/fluffy-countries-knock.md index 509aedd66f..44dddbbca7 100644 --- a/.changeset/ninety-spies-prove.md +++ b/.changeset/fluffy-countries-knock.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-backend': patch --- -Skip empty file names when scaffolding with nunjucks +Bump esbuild to ^0.14.1 diff --git a/.changeset/forty-ligers-protect.md b/.changeset/forty-ligers-protect.md deleted file mode 100644 index e3e46fb7f3..0000000000 --- a/.changeset/forty-ligers-protect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -remove double config dep diff --git a/.changeset/fresh-radios-compete.md b/.changeset/fresh-radios-compete.md new file mode 100644 index 0000000000..221072eb95 --- /dev/null +++ b/.changeset/fresh-radios-compete.md @@ -0,0 +1,14 @@ +--- +'@backstage/plugin-kubernetes': minor +'@backstage/plugin-kubernetes-backend': minor +'@backstage/plugin-kubernetes-common': minor +--- + +Add pod metrics lookup and display in pod table. + +## Backwards incompatible changes + +If your Kubernetes distribution does not have the [metrics server](https://github.com/kubernetes-sigs/metrics-server) installed, +you will need to set the `skipMetricsLookup` config flag to `false`. + +See the [configuration docs](https://backstage.io/docs/features/kubernetes/configuration) for more details. diff --git a/.changeset/fresh-zebras-hug.md b/.changeset/fresh-zebras-hug.md deleted file mode 100644 index c295dc2287..0000000000 --- a/.changeset/fresh-zebras-hug.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-kafka-backend': patch ---- - -Update Kafka configuration types diff --git a/.changeset/giant-drinks-wave.md b/.changeset/giant-drinks-wave.md deleted file mode 100644 index ecf4c8fbdd..0000000000 --- a/.changeset/giant-drinks-wave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/dev-utils': patch ---- - -Migrated to using `@backstage/app-defaults`. diff --git a/.changeset/happy-rice-tickle.md b/.changeset/happy-rice-tickle.md deleted file mode 100644 index 1e39dc2e9c..0000000000 --- a/.changeset/happy-rice-tickle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Added the `isDatabaseConflictError` function. diff --git a/.changeset/hot-dragons-kneel.md b/.changeset/hot-dragons-kneel.md new file mode 100644 index 0000000000..a2b6241b69 --- /dev/null +++ b/.changeset/hot-dragons-kneel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Reject catalog entities that have duplicate fields that vary only in casing. diff --git a/.changeset/hot-walls-fail.md b/.changeset/hot-walls-fail.md deleted file mode 100644 index 3728e10ff6..0000000000 --- a/.changeset/hot-walls-fail.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Migrated the app template use the new `@backstage/app-defaults` for the `createApp` import, since the `createApp` exported by `@backstage/app-core-api` will be removed in the future. - -To migrate an existing application, add the latest version of `@backstage/app-defaults` as a dependency in `packages/app/package.json`, and make the following change to `packages/app/src/App.tsx`: - -```diff --import { createApp, FlatRoutes } from '@backstage/core-app-api'; -+import { createApp } from '@backstage/app-defaults'; -+import { FlatRoutes } from '@backstage/core-app-api'; -``` diff --git a/.changeset/hungry-wombats-happen.md b/.changeset/hungry-wombats-happen.md deleted file mode 100644 index 018e7ae9e8..0000000000 --- a/.changeset/hungry-wombats-happen.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/backend-common': patch -'@backstage/cli': patch ---- - -Update internal usage of `configLoader.loadConfig` that now returns an object instead of an array of configs. diff --git a/.changeset/khaki-rice-kick.md b/.changeset/khaki-rice-kick.md deleted file mode 100644 index 54819b855a..0000000000 --- a/.changeset/khaki-rice-kick.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core-components': patch -'@backstage/plugin-user-settings': patch ---- - -Add Props Icon for Sidebar Item SidebarSearchField and Settings diff --git a/.changeset/little-news-retire.md b/.changeset/little-news-retire.md deleted file mode 100644 index 4304cf2d19..0000000000 --- a/.changeset/little-news-retire.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-bazaar': patch -'@backstage/plugin-bazaar-backend': patch ---- - -A Bazaar project has been extended with the following fields: size, start date (optional), end date (optional) and a responsible person. diff --git a/.changeset/little-numbers-thank.md b/.changeset/little-numbers-thank.md deleted file mode 100644 index f444a904c0..0000000000 --- a/.changeset/little-numbers-thank.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-components': patch ---- - -Changing the `Header` styles to use more theme variables. With this the title `font-size` will not change on resizing the window. diff --git a/.changeset/many-sloths-cross.md b/.changeset/many-sloths-cross.md deleted file mode 100644 index 07cb6d2f61..0000000000 --- a/.changeset/many-sloths-cross.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Refactor and add regression tests for create-app tasks diff --git a/.changeset/moody-snails-admire.md b/.changeset/moody-snails-admire.md deleted file mode 100644 index 7622546989..0000000000 --- a/.changeset/moody-snails-admire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': minor ---- - -Tweaked style insertion logic to make sure that JSS stylesheets always receive the highest priority. diff --git a/.changeset/nasty-impalas-travel.md b/.changeset/nasty-impalas-travel.md deleted file mode 100644 index f6eb321628..0000000000 --- a/.changeset/nasty-impalas-travel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Paths can be specified in backend.reading.allow to further restrict allowed targets diff --git a/.changeset/nine-bananas-mate.md b/.changeset/nine-bananas-mate.md deleted file mode 100644 index e119eb722f..0000000000 --- a/.changeset/nine-bananas-mate.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core-app-api': patch -'@backstage/test-utils': patch ---- - -Migrated to using new `ErrorApiError` and `ErrorApiErrorContext` names. diff --git a/.changeset/ninety-grapes-love.md b/.changeset/ninety-grapes-love.md deleted file mode 100644 index 9143bddd30..0000000000 --- a/.changeset/ninety-grapes-love.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core-components': patch -'@backstage/plugin-auth-backend': patch ---- - -Update OAuthAdapter to create identity.token from identity.idToken if it does not exist, and prevent overwrites to identity.toke. Update login page commonProvider to prefer .token over .idToken diff --git a/.changeset/orange-experts-approve.md b/.changeset/orange-experts-approve.md deleted file mode 100644 index 13d1b022da..0000000000 --- a/.changeset/orange-experts-approve.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Adjust entity query construction to ensure sub-queries are always isolated from one another. diff --git a/.changeset/pretty-eagles-relate.md b/.changeset/pretty-eagles-relate.md new file mode 100644 index 0000000000..9e17be05d3 --- /dev/null +++ b/.changeset/pretty-eagles-relate.md @@ -0,0 +1,10 @@ +--- +'@backstage/cli': patch +--- + +Add cli option to minify the generated code of a plugin or backend package + +``` +backstage-cli plugin:build --minify +backstage-cli backend:build --minify +``` diff --git a/.changeset/rare-lemons-boil.md b/.changeset/rare-lemons-boil.md deleted file mode 100644 index b882ab0b91..0000000000 --- a/.changeset/rare-lemons-boil.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-app-api': patch ---- - -Deprecated the `BackstagePluginWithAnyOutput` type. diff --git a/.changeset/rich-pillows-cough.md b/.changeset/rich-pillows-cough.md deleted file mode 100644 index 959eb69214..0000000000 --- a/.changeset/rich-pillows-cough.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Make sure that server builder `start()` propagates errors (such as failing to bind to the required port) properly and doesn't resolve the promise prematurely. - -After this change, the backend logger will be able to actually capture the error as it happens: - -``` -2021-11-11T10:54:21.334Z backstage info Initializing http server -2021-11-11T10:54:21.335Z backstage error listen EADDRINUSE: address already in use :::7000 code=EADDRINUSE errno=-48 syscall=listen address=:: port=7000 -``` diff --git a/.changeset/rotten-clouds-kick.md b/.changeset/rotten-clouds-kick.md deleted file mode 100644 index 2c4772c6e5..0000000000 --- a/.changeset/rotten-clouds-kick.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-app-api': patch ---- - -Start exporting and marking several types as public to address errors in the API report. diff --git a/.changeset/search-hip-schools-burn.md b/.changeset/search-hip-schools-burn.md new file mode 100644 index 0000000000..12cef5f5ca --- /dev/null +++ b/.changeset/search-hip-schools-burn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Export SearchApi interface from plugin diff --git a/.changeset/sharp-meals-fetch.md b/.changeset/sharp-meals-fetch.md deleted file mode 100644 index 1d3df65ba4..0000000000 --- a/.changeset/sharp-meals-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-plugin-api': minor ---- - -Removed the unused `UserFlags` type. diff --git a/.changeset/sharp-moons-jog.md b/.changeset/sharp-moons-jog.md deleted file mode 100644 index d3329af08e..0000000000 --- a/.changeset/sharp-moons-jog.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-plugin-api': patch ---- - -Deprecated the `theme` property on `AppTheme`, replacing it with `Provider`. See https://backstage.io/docs/api/deprecations#app-theme for more details. diff --git a/.changeset/shiny-starfishes-float.md b/.changeset/shiny-starfishes-float.md deleted file mode 100644 index 0673791375..0000000000 --- a/.changeset/shiny-starfishes-float.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Added a scaffolder backend module template for the `create` command. diff --git a/.changeset/silent-taxis-tan.md b/.changeset/silent-taxis-tan.md deleted file mode 100644 index 286ef40a4c..0000000000 --- a/.changeset/silent-taxis-tan.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-plugin-api': minor ---- - -Remove exports of unused types(`RouteOptions` and `RoutePath`). diff --git a/.changeset/sixty-files-talk.md b/.changeset/sixty-files-talk.md new file mode 100644 index 0000000000..67d8c48b7e --- /dev/null +++ b/.changeset/sixty-files-talk.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-analytics-module-ga': patch +--- + +Support self hosted analytics.js script via `scriptSrc` config option diff --git a/.changeset/slow-moles-act.md b/.changeset/slow-moles-act.md deleted file mode 100644 index ab920bfa5d..0000000000 --- a/.changeset/slow-moles-act.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/test-utils': patch ---- - -Migrated to using `createSpecializedApp`. diff --git a/.changeset/sour-cameras-hide.md b/.changeset/sour-cameras-hide.md deleted file mode 100644 index a31e7a13c3..0000000000 --- a/.changeset/sour-cameras-hide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Fixed a bug where calling `backstage-cli backend:bundle --build-dependencies` with no dependencies to be built would cause all monorepo packages to be built instead. diff --git a/.changeset/spicy-rice-build.md b/.changeset/spicy-rice-build.md deleted file mode 100644 index c0322d778a..0000000000 --- a/.changeset/spicy-rice-build.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Do not redact empty or one-character strings. These imply that it's just a test or local dev, and unnecessarily ruin the log output. diff --git a/.changeset/proud-bottles-cheat.md b/.changeset/swift-clocks-cry.md similarity index 51% rename from .changeset/proud-bottles-cheat.md rename to .changeset/swift-clocks-cry.md index 0c724352c7..8f37a2e340 100644 --- a/.changeset/proud-bottles-cheat.md +++ b/.changeset/swift-clocks-cry.md @@ -2,4 +2,4 @@ '@backstage/backend-common': patch --- -Add `AbortSignal` support to `UrlReader` +Add knexConfig config section diff --git a/.changeset/tame-buckets-move.md b/.changeset/tame-buckets-move.md deleted file mode 100644 index f7ee3deb90..0000000000 --- a/.changeset/tame-buckets-move.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/plugin-azure-devops': patch -'@backstage/plugin-azure-devops-backend': patch -'@backstage/plugin-azure-devops-common': minor ---- - -Improved Date handling for the Azure DevOps set of plugins by using strings and letting the frontend handle the conversion to DateTime diff --git a/.changeset/techdocs-chatt-months-report-too.md b/.changeset/techdocs-chatt-months-report-too.md deleted file mode 100644 index 612a4506a5..0000000000 --- a/.changeset/techdocs-chatt-months-report-too.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Fixed entity triplet case handling for certain locales. diff --git a/.changeset/techdocs-chatty-months-report.md b/.changeset/techdocs-chatty-months-report.md deleted file mode 100644 index 7d3fa773bf..0000000000 --- a/.changeset/techdocs-chatty-months-report.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/techdocs-common': patch ---- - -Added OpenStack Swift case migration support. diff --git a/.changeset/techdocs-famous-donuts-warn.md b/.changeset/techdocs-famous-donuts-warn.md deleted file mode 100644 index 2fd9346ee2..0000000000 --- a/.changeset/techdocs-famous-donuts-warn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@techdocs/cli': patch ---- - -Add support for specifying bucketRootPath for AWS and GCS publishers diff --git a/.changeset/techdocs-orange-cougars-relax.md b/.changeset/techdocs-orange-cougars-relax.md deleted file mode 100644 index 376de6a0e5..0000000000 --- a/.changeset/techdocs-orange-cougars-relax.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/techdocs-common': patch -'@backstage/plugin-techdocs-backend': patch ---- - -Allow amazon web services s3 buckets to pass an server side encryption configuration so they can publish to encrypted buckets diff --git a/.changeset/techdocs-sharp-knives-unite.md b/.changeset/techdocs-sharp-knives-unite.md deleted file mode 100644 index aa3625b883..0000000000 --- a/.changeset/techdocs-sharp-knives-unite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Use a better checkbox rendering in a task list. diff --git a/.changeset/techdocs-silver-plums-speak.md b/.changeset/techdocs-silver-plums-speak.md deleted file mode 100644 index 84d04fc1ef..0000000000 --- a/.changeset/techdocs-silver-plums-speak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Updates reader component used to display techdocs documentation. A previous change made this component not usable out of a page which don't have entityRef in url parameters. Reader component EntityRef parameter is now used instead of url parameters. Techdocs documentation component can now be used in our custom pages. diff --git a/.changeset/tender-gorillas-peel.md b/.changeset/tender-gorillas-peel.md deleted file mode 100644 index f7d7022740..0000000000 --- a/.changeset/tender-gorillas-peel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -bump `@rollup/plugin-commonjs` from 17.1.0 to 21.0.1 diff --git a/.changeset/three-news-worry.md b/.changeset/three-news-worry.md new file mode 100644 index 0000000000..24c7daa240 --- /dev/null +++ b/.changeset/three-news-worry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-bazaar-backend': patch +--- + +Handle migration error when old data is present in the database diff --git a/.changeset/tidy-beans-reflect.md b/.changeset/tidy-beans-reflect.md deleted file mode 100644 index 20796075f0..0000000000 --- a/.changeset/tidy-beans-reflect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Introduces new `backstage-cli create` command to replace `create-plugin` and make space for creating a wider array of things. The create command also adds a new template for creating isomorphic common plugin packages. diff --git a/.changeset/twenty-swans-matter.md b/.changeset/twenty-swans-matter.md deleted file mode 100644 index 55cd3d6dc3..0000000000 --- a/.changeset/twenty-swans-matter.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@backstage/core-app-api': patch ---- - -The `createApp` function from `@backstage/core-app-api` has been deprecated, with two new options being provided as a replacement. - -The first and most commonly used one is `createApp` from the new `@backstage/app-defaults` package, which behaves just like the existing `createApp`. In the future this method is likely to be expanded to add more APIs and other pieces into the default setup, for example the Utility APIs from `@backstage/integration-react`. - -The other option that we now provide is to use `createSpecializedApp` from `@backstage/core-app-api`. This is a more low-level API where you need to provide a full set of options, including your own `components`, `icons`, `defaultApis`, and `themes`. The `createSpecializedApp` way of creating an app is particularly useful if you are not using `@backstage/core-components` or MUI, as it allows you to avoid those dependencies completely. diff --git a/.changeset/violet-panthers-care.md b/.changeset/violet-panthers-care.md deleted file mode 100644 index 7a43c87f37..0000000000 --- a/.changeset/violet-panthers-care.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -adds getDefaultProcessor method to CatalogBuilder diff --git a/.changeset/yellow-deers-act.md b/.changeset/yellow-deers-act.md deleted file mode 100644 index a9008ea41e..0000000000 --- a/.changeset/yellow-deers-act.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-plugin-api': patch ---- - -Deprecated the `Error` and `ErrorContext` types, replacing them with identical `ErrorApiError` and `ErrorApiErrorContext` types. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9be92d294e..ed4b61099f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,8 @@ /docs/features/techdocs @backstage/techdocs-core /docs/features/search @backstage/techdocs-core /docs/assets/search @backstage/techdocs-core +/plugins/allure @backstage/reviewers @deepak-bhardwaj-ps +/plugins/circleci @backstage/reviewers @adamdmharvey /plugins/code-coverage @backstage/reviewers @alde @nissayeva /plugins/code-coverage-backend @backstage/reviewers @alde @nissayeva /plugins/cost-insights @backstage/silver-lining @@ -18,6 +20,20 @@ /plugins/techdocs-backend @backstage/techdocs-core /plugins/ilert @backstage/reviewers @yacut /plugins/home @backstage/techdocs-core +/plugins/azure-devops @backstage/reviewers @marleypowell @awanlin +/plugins/azure-devops-backend @backstage/reviewers @marleypowell @awanlin +/plugins/azure-devops-common @backstage/reviewers @marleypowell @awanlin +/plugins/jenkins @backstage/reviewers @timja +/plugins/jenkins-backend @backstage/reviewers @timja +/plugins/kafka @backstage/reviewers @nirga +/plugins/kafka-backend @backstage/reviewers @nirga +/plugins/scaffolder-backend-module-yeoman @backstage/reviewers @pawelmitka +/plugins/scaffolder-backend-module-rails @backstage/reviewers @angeliski +/plugins/git-release-manager @backstage/reviewers @erikengervall +/tech-insights-backend @backstage/reviewers @xantier @iain-b +/tech-insights-backend-module-jsonfc @backstage/reviewers @xantier @iain-b +/tech-insights-tech-insights-common @backstage/reviewers @xantier @iain-b +/tech-insights-tech-insights-node @backstage/reviewers @xantier @iain-b /packages/embedded-techdocs-app @backstage/techdocs-core /packages/search-common @backstage/techdocs-core /packages/techdocs-cli @backstage/techdocs-core diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 3cd0d53720..0000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,21 +0,0 @@ -# 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 - - plugin - - help wanted - - good first issue - - rfc -# Label to use when marking an issue as stale -staleLabel: stale -# Comment to post when marking an issue as stale. Set to `false` to disable -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 d65e31b4c4..4ec62923d8 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -189,6 +189,7 @@ oidc Okta onboarding Onboarding +OpenShift orgs pagerduty pageview diff --git a/.github/workflows/snyk-github-issue-sync.yml b/.github/workflows/snyk-github-issue-sync.yml index 0c50b2c505..7b93374609 100644 --- a/.github/workflows/snyk-github-issue-sync.yml +++ b/.github/workflows/snyk-github-issue-sync.yml @@ -6,6 +6,8 @@ on: jobs: sync: + if: github.repository == 'backstage/backstage' # prevent running on forks + runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000..96a2a20109 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,30 @@ +name: 'Stale workflow' +on: + workflow_dispatch: + schedule: + - cron: '*/10 * * * *' # run every 10 minutes as it also removes labels. + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@main + id: stale + with: + stale-issue-message: > + 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. + days-before-issue-stale: 60 + days-before-issue-close: 7 + exempt-issue-labels: 'pinned,security,plugin,help wanted,good first issue,rfc' + stale-issue-label: stale + stale-pr-message: > + This PR has been automatically marked as stale because it has not had + recent activity from the author. It will be closed if no further activity occurs. + If you are the author and the PR has been closed, feel free to re-open the PR and continue the contribution! + days-before-pr-stale: 7 + days-before-pr-close: 3 + exempt-pr-labels: reviewer-approved,awaiting-review + stale-pr-label: stale + operations-per-run: 100 diff --git a/.tugboat/config.yml b/.tugboat/config.yml index b8c3c127c8..883708bef7 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -1,7 +1,7 @@ services: backstage: image: tugboatqa/node:lts - expose: 7000 + expose: 7007 default: true commands: init: @@ -14,4 +14,4 @@ services: - yarn workspace example-app build start: # wget the endpoint. Will retry every 2 seconds. 30 retries = 1m for service to come up. Plenty. - - wget -O /dev/null -o /dev/null --tries=30 --timeout=5 --retry-connrefused http://localhost:7000 + - wget -O /dev/null -o /dev/null --tries=30 --timeout=5 --retry-connrefused http://localhost:7007 diff --git a/ADOPTERS.md b/ADOPTERS.md index 92912fe144..529c22d144 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -69,3 +69,7 @@ | [Unity](https://unity.com) | [Ted Cordery](https://github.com/TeddyBallGame) | A centralized service catalog with documentation for our service engineers. | | [PicPay](https://www.picpay.com) | [Luis Baroni](https://github.com/lcsbaroni), [Renata Poluceno](https://github.com/renatapoluceno), [PicPay](https://github.com/picpay) | Developer portal for building services throught templates, service catalog with ownership of services, documentation and metrics providing autonomy and visibility for all. | | [Epic Games](https://www.epicgames.com) | [Brian Jung](https://github.com/brian-at-epic), [Jeff Goldian](https://github.com/jeffgoldian-Epic) | Developer Portal: Service Catalog, Documentation, Software Templates and more making our internal teams' lives easier! | +| [Globo](https://globo.com) | [Carlos Gusmão](https://github.com/caeugusmao), [Guilherme Vierno](https://github.com/vierno), [Denis Aoki](https://github.com/dnsaoki2), [Maycon Dionisio](https://github.com/MayconDionisio), | Reduce the friction of accessing the information engineers need about Globo's digital services through a coherent and centralized experience. | +| [QBE](https://www.qbe.com/) | [Daniel Steel](https://github.com/danielsteelqbe), [Pete Jespers](https://github.com/petejespersqbe) | Developer portal allowing our global teams to explore and create applications, documentation and cloud infrastructure easily and quickly 🚀 | +| [LogMeIn](https://www.logmein.com) | [Lorenzo Orsatti](https://github.com/lorsatti) | Improve onboarding experience of new developers. Discover faster and painlessly developer documentation, API definitions and team information. Provide useful dev metrics in a central place. Provide easy-to-use templates for new services. | +| [Telstra](https://www.telstra.com.au) | [@kiranpatel11](https://github.com/kiranpatel11), [JasonC](https://github.com/JasonC17) | Primary usage: software catalog and templates
Emerging usage : TechDocs, Explore Ecosystem, TechRadar, etc | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99d63cf0b4..31f912f43e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -122,7 +122,9 @@ We use [changesets](https://github.com/atlassian/changesets) to help us prepare Any time a patch, minor, or major change aligning to [Semantic Versioning](https://semver.org) is made to any published package in `packages/` or `plugins/`, a changeset should be used. It helps to align your change to the [Backstage stability index](https://backstage.io/docs/overview/stability-index) for the package you are changing, for example, when to provide additional clarity on deprecation or impacting changes which will then be included into CHANGELOGs. -In general, changesets are not needed for the documentation, build utilities, contributed samples in `contrib/`, or the [example `packages/app`](packages/app). +In general, changesets are only needed for changes to packages within `packages/` or `plugins/` directories, and only for the packages that are not marked as `private`. Changesets are also not needed for changes that do not affect the published version of each package, for example changes to tests or in-line source code comments. + +Changesets **are** needed for new packages, as that is what triggers the package to be part of the next release. ### How to create a changeset diff --git a/app-config.yaml b/app-config.yaml index 8aa3bc569c..4adaea0da4 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -23,9 +23,14 @@ app: title: '#backstage' backend: - baseUrl: http://localhost:7000 + # Used for enabling authentication, secret is shared by all backend plugins + # See backend-to-backend-auth.md in the docs for information on the format + # auth: + # keys: + # - secret: ${BACKEND_SECRET} + baseUrl: http://localhost:7007 listen: - port: 7000 + port: 7007 database: client: sqlite3 connection: ':memory:' @@ -272,24 +277,6 @@ scaffolder: # email: scaffolder@backstage.io # Use to customize the default commit message when new components are created # defaultCommitMessage: 'Initial commit' - github: - token: ${GITHUB_TOKEN} - visibility: public # or 'internal' or 'private' - gitlab: - api: - baseUrl: https://gitlab.com - token: ${GITLAB_TOKEN} - visibility: public # or 'internal' or 'private' - azure: - baseUrl: https://dev.azure.com/{your-organization} - api: - token: ${AZURE_TOKEN} - bitbucket: - api: - host: https://bitbucket.org - username: ${BITBUCKET_USERNAME} - token: ${BITBUCKET_TOKEN} - visibility: public # or or 'private' auth: ### Add auth.keyStore.provider to more granularly control how to store JWK data when running diff --git a/contrib/chart/backstage/files/app-config.development.yaml.tpl b/contrib/chart/backstage/files/app-config.development.yaml.tpl index 6d3ded16c1..105ba3b259 100644 --- a/contrib/chart/backstage/files/app-config.development.yaml.tpl +++ b/contrib/chart/backstage/files/app-config.development.yaml.tpl @@ -1,7 +1,7 @@ backend: lighthouseHostname: {{ include "lighthouse.serviceName" . | quote }} listen: - port: {{ .Values.appConfig.backend.listen.port | default 7000 }} + port: {{ .Values.appConfig.backend.listen.port | default 7007 }} database: client: {{ .Values.appConfig.backend.database.client | quote }} connection: diff --git a/contrib/chart/backstage/templates/ingress.yaml b/contrib/chart/backstage/templates/ingress.yaml index 7231afda13..9340467800 100644 --- a/contrib/chart/backstage/templates/ingress.yaml +++ b/contrib/chart/backstage/templates/ingress.yaml @@ -1,7 +1,13 @@ {{- $frontendUrl := urlParse .Values.appConfig.app.baseUrl}} {{- $backendUrl := urlParse .Values.appConfig.backend.baseUrl}} {{- $lighthouseUrl := urlParse .Values.appConfig.lighthouse.baseUrl}} + +{{/* Determine the api type for the ingress */}} +{{- if lt .Capabilities.KubeVersion.Minor "19" }} apiVersion: networking.k8s.io/v1beta1 +{{- else if ge .Capabilities.KubeVersion.Minor "19" }} +apiVersion: networking.k8s.io/v1 +{{- end }} kind: Ingress metadata: name: {{ include "backstage.fullname" . }}-ingress diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index 9f70dac6dc..6ffe076e57 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -26,7 +26,7 @@ backend: repository: martinaif/backstage-k8s-demo-backend tag: 20210423T1550 pullPolicy: IfNotPresent - containerPort: 7000 + containerPort: 7007 serviceType: ClusterIP postgresCertMountEnabled: true resources: @@ -96,7 +96,7 @@ appConfig: backend: baseUrl: https://demo.example.com listen: - port: 7000 + port: 7007 cors: origin: https://demo.example.com database: diff --git a/contrib/docker/devops/makefile b/contrib/docker/devops/makefile index 3ae89a161b..048edc8886 100644 --- a/contrib/docker/devops/makefile +++ b/contrib/docker/devops/makefile @@ -9,8 +9,8 @@ docker_name_prefix := backstage-make # to the host computer frontend_port := 3000 frontend_host_port := 3000 -backend_port := 7000 -backend_host_port := 7000 +backend_port := 7007 +backend_host_port := 7007 # path to this "makefile" my_dir_path = $(dir $(CURDIR)/$(firstword $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST))) @@ -191,10 +191,10 @@ test: check-tests check: check-code check-docs check-type-dependencies check-styles # run development instance -# BUG: the frontend seems to run on "$(backend_port)" (7000 default). +# BUG: the frontend seems to run on "$(backend_port)" (7007 default). # The documentation states "This is going to start two things, -# the frontend (:3000) and the backend (:7000)." -# However, the frontend seems to end up running on 7000. +# the frontend (:3000) and the backend (:7007)." +# However, the frontend seems to end up running on 7007. .PHONY: dev dev: build @docker run --rm -it \ diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml index f0695753fd..c8d60a9c1b 100644 --- a/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml +++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/backend.yaml @@ -22,6 +22,6 @@ spec: image: spotify/backstage-backend:latest imagePullPolicy: IfNotPresent ports: - - containerPort: 7000 + - containerPort: 7007 name: backend protocol: TCP diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml index d91808ed28..a293dc12fb 100644 --- a/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml +++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/backstage/values.yaml @@ -63,7 +63,7 @@ backend: pullPolicy: IfNotPresent service: type: ClusterIP - port: 7000 + port: 7007 ingress: enabled: false annotations: diff --git a/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml b/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml index 4d947b7afc..e1da35b598 100644 --- a/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml +++ b/contrib/kubernetes/basic_kubernetes_example_with_helm/service.yaml @@ -30,6 +30,6 @@ spec: component: backend ports: - name: backend - port: 7000 + port: 7007 protocol: TCP targetPort: backend diff --git a/cypress/cypress.json b/cypress/cypress.json index 3ef3df8e65..7335d0f62a 100644 --- a/cypress/cypress.json +++ b/cypress/cypress.json @@ -1,5 +1,5 @@ { - "baseUrl": "http://localhost:7000", + "baseUrl": "http://localhost:7007", "integrationFolder": "./src/integration", "supportFile": "./src/support", "fixturesFolder": "./src/fixtures", diff --git a/cypress/yarn.lock b/cypress/yarn.lock index baf0d92715..96bc765d0d 100644 --- a/cypress/yarn.lock +++ b/cypress/yarn.lock @@ -13,9 +13,9 @@ figures "^1.7.0" "@cypress/request@^2.88.5": - version "2.88.5" - resolved "https://registry.npmjs.org/@cypress/request/-/request-2.88.5.tgz#8d7ecd17b53a849cfd5ab06d5abe7d84976375d7" - integrity sha512-TzEC1XMi1hJkywWpRfD2clreTa/Z+lOrXDCxxBTBPEcY5azdPi56A6Xw+O4tWJnaJH3iIE7G5aDXZC6JgRZLcA== + version "2.88.10" + resolved "https://registry.npmjs.org/@cypress/request/-/request-2.88.10.tgz#b66d76b07f860d3a4b8d7a0604d020c662752cce" + integrity sha512-Zp7F+R93N0yZyG34GutyTNr+okam7s/Fzc1+i3kcqOP8vk6OuajuE9qZJ6Rs+10/1JFtXFYMdyarnU1rZuJesg== dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -24,19 +24,17 @@ extend "~3.0.2" forever-agent "~0.6.1" form-data "~2.3.2" - har-validator "~5.1.3" - http-signature "~1.2.0" + http-signature "~1.3.6" is-typedarray "~1.0.0" isstream "~0.1.2" json-stringify-safe "~5.0.1" mime-types "~2.1.19" - oauth-sign "~0.9.0" performance-now "^2.1.0" qs "~6.5.2" safe-buffer "^5.1.2" tough-cookie "~2.5.0" tunnel-agent "^0.6.0" - uuid "^3.3.2" + uuid "^8.3.2" "@cypress/xvfb@^1.2.4": version "1.2.4" @@ -68,16 +66,6 @@ resolved "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47" integrity sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg== -ajv@^6.12.3: - version "6.12.6" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - ansi-escapes@^3.0.0: version "3.2.0" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" @@ -539,16 +527,6 @@ extsprintf@^1.2.0: resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= -fast-deep-equal@^3.1.1: - version "3.1.3" - resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - fd-slicer@~1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" @@ -645,19 +623,6 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0: resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.5.tgz#bc18864a6c9fc7b303f2e2abdb9155ad178fbe29" integrity sha512-kBBSQbz2K0Nyn+31j/w36fUfxkBW9/gfwRWdUY1ULReH3iokVJgddZAFcD1D0xlgTmFxJCbUkUclAlc6/IDJkw== -har-schema@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= - -har-validator@~5.1.3: - version "5.1.5" - resolved "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" - integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== - dependencies: - ajv "^6.12.3" - har-schema "^2.0.0" - has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" @@ -675,14 +640,14 @@ has-flag@^4.0.0: resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -http-signature@~1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= +http-signature@~1.3.6: + version "1.3.6" + resolved "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9" + integrity sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw== dependencies: assert-plus "^1.0.0" - jsprim "^1.2.2" - sshpk "^1.7.0" + jsprim "^2.0.2" + sshpk "^1.14.1" human-signals@^1.1.1: version "1.1.1" @@ -796,15 +761,10 @@ jsbn@~0.1.0: resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-schema@0.2.3: - version "0.2.3" - resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" - integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= +json-schema@0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== json-stringify-safe@~5.0.1: version "5.0.1" @@ -820,14 +780,14 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" -jsprim@^1.2.2: - version "1.4.1" - resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" - integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= +jsprim@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d" + integrity sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ== dependencies: assert-plus "1.0.0" extsprintf "1.3.0" - json-schema "0.2.3" + json-schema "0.4.0" verror "1.10.0" lazy-ass@^1.6.0: @@ -985,11 +945,6 @@ number-is-nan@^1.0.0: resolved "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= -oauth-sign@~0.9.0: - version "0.9.0" - resolved "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" - integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== - object-assign@^4.1.0: version "4.1.1" resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -1084,7 +1039,7 @@ punycode@1.3.2: resolved "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^2.1.0, punycode@^2.1.1: +punycode@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== @@ -1191,7 +1146,7 @@ slice-ansi@0.0.4: resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" integrity sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU= -sshpk@^1.7.0: +sshpk@^1.14.1: version "1.16.1" resolved "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== @@ -1353,13 +1308,6 @@ untildify@^4.0.0: resolved "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== -uri-js@^4.2.2: - version "4.4.1" - resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" - integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== - dependencies: - punycode "^2.1.0" - url@^0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" @@ -1373,10 +1321,10 @@ util-deprecate@~1.0.1: resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= -uuid@^3.3.2: - version "3.4.0" - resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" - integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== verror@1.10.0: version "1.10.0" diff --git a/docs/api/deprecations.md b/docs/api/deprecations.md index 774c8ef03d..aca7e42d96 100644 --- a/docs/api/deprecations.md +++ b/docs/api/deprecations.md @@ -54,3 +54,10 @@ const darkTheme = { ), }; ``` + +Note that the existing `AppTheme` type still requires the `theme` property to be +set since it's the type that's consumed in the `AppThemeApi`, and it would be a +breaking change to make `theme` optional. This means that if you currently +construct the themes that you pass on to `createApp` using `AppTheme` as an +intermediate type, you will need to work around this in some way, for example by +passing the themes to `createApp` more directly. diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index 133653df44..14920513af 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -291,7 +291,7 @@ The figure below shows the relationship between fooApiRef.
-Figure showing the relationship between utility APIs, the apps that provide them, and the plugins that consume them +Figure showing the relationship between utility APIs, the apps that provide them, and the plugins that consume them
The current method for connecting Utility API providers and consumers is via the diff --git a/docs/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md b/docs/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md index a9a5ef5849..0893c105f4 100644 --- a/docs/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md +++ b/docs/architecture-decisions/adr012-use-luxon-locale-and-date-presets.md @@ -1,6 +1,6 @@ --- id: adrs-adr012 -title: ADR000: Use Luxon.toLocaleString and date/time presets +title: ADR012: Use Luxon.toLocaleString and date/time presets description: Architecture Decision Record (ADR) for using Luxon's toLocaleString method and date/time presets for displaying dates and times --- diff --git a/docs/assets/software-templates/grouped-templates.png b/docs/assets/software-templates/grouped-templates.png new file mode 100644 index 0000000000..9a3689ad1e Binary files /dev/null and b/docs/assets/software-templates/grouped-templates.png differ diff --git a/docs/auth/add-auth-provider.md b/docs/auth/add-auth-provider.md index 472a6a0abc..07d6869fe3 100644 --- a/docs/auth/add-auth-provider.md +++ b/docs/auth/add-auth-provider.md @@ -229,7 +229,7 @@ name. ### Test the new provider -You can `curl -i localhost:7000/api/auth/providerA/start` and which should +You can `curl -i localhost:7007/api/auth/providerA/start` and which should provide a `302` redirect with a `Location` header. Paste the url from that header into a web browser and you should be able to trigger the authorization flow. diff --git a/docs/auth/atlassian/provider.md b/docs/auth/atlassian/provider.md index 38774ca3cc..cded6e73ca 100644 --- a/docs/auth/atlassian/provider.md +++ b/docs/auth/atlassian/provider.md @@ -28,7 +28,7 @@ Name your integration and click on the `Create` button. Settings for local development: -- Callback URL: `http://localhost:7000/api/auth/atlassian` +- Callback URL: `http://localhost:7007/api/auth/atlassian` - Use rotating refresh tokens - For permissions, you **must** enable `View user profile` for the currently logged-in user, under `User identity API` diff --git a/docs/auth/auth0/provider.md b/docs/auth/auth0/provider.md index 05553c3fd3..80106a73c9 100644 --- a/docs/auth/auth0/provider.md +++ b/docs/auth/auth0/provider.md @@ -17,7 +17,7 @@ provider that can authenticate users using OAuth. - Application type: Single Page Web Application 4. Click on the Settings tab 5. Add under `Application URIs` > `Allowed Callback URLs`: - `http://localhost:7000/api/auth/auth0/handler/frame` + `http://localhost:7007/api/auth/auth0/handler/frame` 6. Click `Save Changes` ## Configuration diff --git a/docs/auth/bitbucket/provider.md b/docs/auth/bitbucket/provider.md index ae09d5dbee..63dfb815e0 100644 --- a/docs/auth/bitbucket/provider.md +++ b/docs/auth/bitbucket/provider.md @@ -20,7 +20,7 @@ Click Add Consumer. Settings for local development: - Application name: Backstage (or your custom app name) -- Callback URL: `http://localhost:7000/api/auth/bitbucket` +- Callback URL: `http://localhost:7007/api/auth/bitbucket` - Other are optional - (IMPORTANT) **Permissions: Account - Read, Workspace membership - Read** diff --git a/docs/auth/github/provider.md b/docs/auth/github/provider.md index d8803e392d..14b99bfea5 100644 --- a/docs/auth/github/provider.md +++ b/docs/auth/github/provider.md @@ -24,7 +24,7 @@ Settings for local development: - Application name: Backstage (or your custom app name) - Homepage URL: `http://localhost:3000` -- Authorization callback URL: `http://localhost:7000/api/auth/github` +- Authorization callback URL: `http://localhost:7007/api/auth/github` ## Configuration diff --git a/docs/auth/gitlab/provider.md b/docs/auth/gitlab/provider.md index fd64ddac14..90939a1f03 100644 --- a/docs/auth/gitlab/provider.md +++ b/docs/auth/gitlab/provider.md @@ -17,7 +17,7 @@ should point to your Backstage backend auth handler. Settings for local development: - Name: Backstage (or your custom app name) -- Redirect URI: `http://localhost:7000/api/auth/gitlab/handler/frame` +- Redirect URI: `http://localhost:7007/api/auth/gitlab/handler/frame` - Scopes: read_user ## Configuration diff --git a/docs/auth/google/provider.md b/docs/auth/google/provider.md index 6216b21704..d3cd8f2dd2 100644 --- a/docs/auth/google/provider.md +++ b/docs/auth/google/provider.md @@ -26,7 +26,7 @@ To support Google authentication, you must create OAuth credentials: - `Name`: Backstage (or your custom app name) - `Authorized JavaScript origins`: http://localhost:3000 - `Authorized Redirect URIs`: - http://localhost:7000/api/auth/google/handler/frame + http://localhost:7007/api/auth/google/handler/frame 7. Click Create ## Configuration diff --git a/docs/auth/index.md b/docs/auth/index.md index 0c03e33900..4a8d225048 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -16,8 +16,10 @@ Backstage identity information in your app or plugins. Backstage comes with many common authentication providers in the core library: +- [Atlassian](atlassian/provider.md) - [Auth0](auth0/provider.md) - [Azure](microsoft/provider.md) +- [Bitbucket](bitbucket/provider.md) - [GitHub](github/provider.md) - [GitLab](gitlab/provider.md) - [Google](google/provider.md) diff --git a/docs/auth/microsoft/provider.md b/docs/auth/microsoft/provider.md index 1b81ff76f1..1e24235f1a 100644 --- a/docs/auth/microsoft/provider.md +++ b/docs/auth/microsoft/provider.md @@ -21,7 +21,7 @@ To support Azure authentication, you must create an App Registration: 4. Register an application - Name: Backstage (or your custom app name) - Redirect URI: Web > - `http://localhost:7000/api/auth/microsoft/handler/frame` + `http://localhost:7007/api/auth/microsoft/handler/frame` 5. Navigate to **Certificates & secrets > New client secret** to create a secret ## Configuration diff --git a/docs/auth/okta/provider.md b/docs/auth/okta/provider.md index b5aaabe4f1..35394094f8 100644 --- a/docs/auth/okta/provider.md +++ b/docs/auth/okta/provider.md @@ -22,8 +22,8 @@ To add Okta authentication, you must create an Application from Okta: - `App integration name`: `Backstage` (or your custom app name) - `Grant type`: `Authorization Code` & `Refresh Token` - `Sign-in redirect URIs`: - `http://localhost:7000/api/auth/okta/handler/frame` - - `Sign-out redirect URIs`: `http://localhost:7000` + `http://localhost:7007/api/auth/okta/handler/frame` + - `Sign-out redirect URIs`: `http://localhost:7007` - `Controlled access`: (select as appropriate) - Click Save diff --git a/docs/auth/onelogin/provider.md b/docs/auth/onelogin/provider.md index a11304ae84..c3572367d2 100644 --- a/docs/auth/onelogin/provider.md +++ b/docs/auth/onelogin/provider.md @@ -18,7 +18,7 @@ To support OneLogin authentication, you must create an Application: 3. Click Save 4. Go to the Configuration tab for the Application and set: - `Login Url`: `http://localhost:3000` - - `Redirect URIs`: `http://localhost:7000/api/auth/onelogin/handler/frame` + - `Redirect URIs`: `http://localhost:7007/api/auth/onelogin/handler/frame` 5. Click Save 6. Go to the SSO tab for the Application and set: - `Token Endpoint` > `Authentication Method`: `POST` diff --git a/docs/conf/writing.md b/docs/conf/writing.md index a0e29820ed..7945d6c980 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -16,8 +16,8 @@ app: baseUrl: http://localhost:3000 backend: - listen: 0.0.0.0:7000 - baseUrl: http://localhost:7000 + listen: 0.0.0.0:7007 + baseUrl: http://localhost:7007 organization: name: CNCF diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 7230f5cf16..6f58abe769 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -7,9 +7,9 @@ description: How to build a Backstage Docker image for deployment This section describes how to build a Backstage App into a deployable Docker image. It is split into three sections, first covering the host build approach, -which is recommended due its speed and more efficient and often simpler caching. -The second section covers a full multi-stage Docker build, and the last section -covers how to deploy the frontend and backend as separate images. +which is recommended due to its speed and more efficient and often simpler +caching. The second section covers a full multi-stage Docker build, and the last +section covers how to deploy the frontend and backend as separate images. Something that goes for all of these docker deployment strategies is that they are stateless, so for a production deployment you will want to set up and @@ -105,11 +105,11 @@ docker image build . -f packages/backend/Dockerfile --tag backstage To try out the image locally you can run the following: ```sh -docker run -it -p 7000:7000 backstage +docker run -it -p 7007:7007 backstage ``` You should then start to get logs in your terminal, and then you can open your -browser at `http://localhost:7000` +browser at `http://localhost:7007` ## Multi-stage Build @@ -208,11 +208,11 @@ docker image build -t backstage . To try out the image locally you can run the following: ```sh -docker run -it -p 7000:7000 backstage +docker run -it -p 7007:7007 backstage ``` You should then start to get logs in your terminal, and then you can open your -browser at `http://localhost:7000` +browser at `http://localhost:7007` ## Separate Frontend diff --git a/docs/deployment/k8s.md b/docs/deployment/k8s.md index 2c52e0e308..a80ba1816e 100644 --- a/docs/deployment/k8s.md +++ b/docs/deployment/k8s.md @@ -351,7 +351,7 @@ spec: imagePullPolicy: IfNotPresent ports: - name: http - containerPort: 7000 + containerPort: 7007 envFrom: - secretRef: name: postgres-secrets @@ -361,11 +361,11 @@ spec: # https://backstage.io/docs/plugins/observability#health-checks # readinessProbe: # httpGet: -# port: 7000 +# port: 7007 # path: /healthcheck # livenessProbe: # httpGet: -# port: 7000 +# port: 7007 # path: /healthcheck ``` @@ -449,7 +449,7 @@ spec: ``` The `selector` here is telling the Service which pods to target, and the port -mapping translates normal HTTP port 80 to the backend http port (7000) on the +mapping translates normal HTTP port 80 to the backend http port (7007) on the pod. Apply this Service to the Kubernetes cluster: @@ -464,10 +464,10 @@ reveal**_, you can forward a local port to the service: ```shell $ sudo kubectl port-forward --namespace=backstage svc/backstage 80:80 -Forwarding from 127.0.0.1:80 -> 7000 +Forwarding from 127.0.0.1:80 -> 7007 ``` -This shows port 7000 since `port-forward` doesn't _really_ support services, so +This shows port 7007 since `port-forward` doesn't _really_ support services, so it cheats by looking up the first pod for a service and connecting to the mapped pod port. @@ -486,7 +486,7 @@ organization: backend: baseUrl: http://localhost listen: - port: 7000 + port: 7007 cors: origin: http://localhost ``` diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 6b3991144b..07a12f54a0 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -26,6 +26,7 @@ kubernetes: name: minikube authProvider: 'serviceAccount' skipTLSVerify: false + skipMetricsLookup: true serviceAccountToken: ${K8S_MINIKUBE_TOKEN} dashboardUrl: http://127.0.0.1:64713 # url copied from running the command: minikube service kubernetes-dashboard -n kubernetes-dashboard dashboardApp: standard @@ -37,6 +38,7 @@ kubernetes: projectId: 'gke-clusters' region: 'europe-west1' skipTLSVerify: true + skipMetricsLookup: true ``` ### `serviceLocatorMethod` @@ -86,8 +88,13 @@ cluster. Valid values are: ##### `clusters.\*.skipTLSVerify` -This determines whether or not the Kubernetes client verifies the TLS -certificate presented by the API server. Defaults to `false`. +This determines whether the Kubernetes client verifies the TLS certificate +presented by the API server. Defaults to `false`. + +##### `clusters.\*.skipMetricsLookup` + +This determines whether the Kubernetes client looks up resource metrics +CPU/Memory for pods returned by the API server. Defaults to `false`. ##### `clusters.\*.serviceAccountToken` (optional) @@ -188,8 +195,13 @@ regions. ##### `skipTLSVerify` -This determines whether or not the Kubernetes client verifies the TLS -certificate presented by the API server. Defaults to `false`. +This determines whether the Kubernetes client verifies the TLS certificate +presented by the API server. Defaults to `false`. + +##### `skipMetricsLookup` + +This determines whether the Kubernetes client looks up resource metrics +CPU/Memory for pods returned by the API server. Defaults to `false`. ### `customResources` (optional) @@ -219,6 +231,26 @@ The custom resource's apiVersion. The plural representing the custom resource. +### `apiVersionOverrides` (optional) + +Overrides for the API versions used to make requests for the corresponding +objects. If using a legacy Kubernetes version, you may use this config to +override the default API versions to ones that are supported by your cluster. + +Example: + +```yaml +--- +kubernetes: + apiVersionOverrides: + cronjobs: 'v1beta1' +``` + +For more information on which API versions are supported by your cluster, please +view the Kubernetes API docs for your Kubernetes version (e.g. +[API Groups for v1.22](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.22/#-strong-api-groups-strong-) +) + ### Role Based Access Control The current RBAC permissions required are read-only cluster wide, for the diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md index 27219e529a..285e399cb4 100644 --- a/docs/features/search/getting-started.md +++ b/docs/features/search/getting-started.md @@ -114,13 +114,15 @@ const routes = ( In `Root.tsx`, add the `SidebarSearchModal` component: ```bash -import { SidebarSearchModal } from '@backstage/plugin-search'; +import { SidebarSearchModal, SearchContextProvider } from '@backstage/plugin-search'; export const Root = ({ children }: PropsWithChildren<{}>) => ( - + + + ... ``` @@ -154,13 +156,17 @@ import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend'; export default async function createPlugin({ logger, discovery, + tokenManager, }: PluginEnvironment) { const searchEngine = new LunrSearchEngine({ logger }); const indexBuilder = new IndexBuilder({ logger, searchEngine }); indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ discovery }), + collator: new DefaultCatalogCollator({ + discovery, + tokenManager, + }), }); const { scheduler } = await indexBuilder.build(); @@ -285,7 +291,10 @@ const indexBuilder = new IndexBuilder({ logger, searchEngine }); indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ discovery }), + collator: new DefaultCatalogCollator({ + discovery, + tokenManager, + }), }); indexBuilder.addCollator({ @@ -303,6 +312,9 @@ its `defaultRefreshIntervalSeconds` value, like this: ```typescript {3} indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ discovery }), + collator: new DefaultCatalogCollator({ + discovery, + tokenManager, + }), }); ``` diff --git a/docs/features/search/how-to-guides.md b/docs/features/search/how-to-guides.md index 621d46b51e..4ba44b8ef4 100644 --- a/docs/features/search/how-to-guides.md +++ b/docs/features/search/how-to-guides.md @@ -2,7 +2,7 @@ id: how-to-guides title: Search "HOW TO" guides sidebar_label: "HOW TO" guides -description: Search "HOW TO" guides +description: Search "HOW TO" guides --- ## How to implement your own Search API @@ -74,6 +74,7 @@ indexBuilder.addCollator({ collator: DefaultTechDocsCollator.fromConfig(config, { discovery, logger, + tokenManager, }), }); ``` diff --git a/docs/features/search/search-engines.md b/docs/features/search/search-engines.md index a0302ce279..1331c73b72 100644 --- a/docs/features/search/search-engines.md +++ b/docs/features/search/search-engines.md @@ -4,9 +4,9 @@ title: Search Engines description: Choosing and configuring your search engine for Backstage --- -Backstage supports 2 search engines by default, an in-memory engine called Lunr -and ElasticSearch. You can configure your own search engines by implementing the -provided interface as mentioned in the +Backstage supports 3 search engines by default, an in-memory engine called Lunr, +ElasticSearch and Postgres. You can configure your own search engines by +implementing the provided interface as mentioned in the [search backend documentation.](./getting-started.md#Backend) Provided search engine implementations have their own way of constructing diff --git a/docs/features/software-templates/configuration.md b/docs/features/software-templates/configuration.md index 737f77f67b..b57bb96818 100644 --- a/docs/features/software-templates/configuration.md +++ b/docs/features/software-templates/configuration.md @@ -53,3 +53,30 @@ You can do so by including the following lines in the last step of your RUN apt-get update && apt-get install -y python3 python3-pip RUN pip3 install cookiecutter ``` + +### Customizing the ScaffolderPage with Grouping and Filtering + +Once you have more than a few software templates you may want to customize your +`ScaffolderPage` by grouping and surfacing certain templates together. You can +accomplish this by creating `groups` and passing them to your `ScaffolderPage` +like below + +``` + + entity?.metadata?.tags?.includes('recommended') ?? false, + }, + ]} +/> +``` + +This code will group all templates with the 'recommended' tag together at the +top of the page above any other templates not filtered by this group or others. + +You can also further customize groups by passing in a `titleComponent` instead +of a `title` which will be a component to use as the header instead of just the +default `ContentHeader` with the `title` set as it's value. +![Grouped Templates](../../assets/software-templates/grouped-templates.png) diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 1434d62a29..a854543acb 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -12,7 +12,7 @@ code, template in some variables, and then publish the template to some locations like GitHub or GitLab. ### Getting Started diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index 07c4891a6c..a564200bfe 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -8,6 +8,10 @@ If you're wanting to extend the functionality of the Scaffolder, you can do so by writing custom actions which can be used along side our [built-in actions](./builtin-actions.md). +> Note: When adding custom actions, the actions array will **replace the +> built-in actions too**. To ensure you can continue to include he builtin +> actions, see below to include them during registration of your action. + ### Writing your Custom Action Your custom action can live where you choose, but simplest is to include it @@ -91,6 +95,8 @@ argument. It looks like the following: - `createTemporaryDirectory` a function to call to give you a temporary directory somewhere on the runner so you can store some files there rather than polluting the `workspacePath` +- `ctx.metadata` - an object containing a `name` field, indicating the template + name. More metadata fields may be added later. ### Registering Custom Actions @@ -113,22 +119,6 @@ return await createRouter({ There's another property you can pass here, which is an array of `actions` which will set the available actions that the scaffolder has access to. -```ts -const actions = [createNewFileAction()]; -return await createRouter({ - containerRunner, - logger, - config, - database, - catalogClient, - reader, - actions, -}); -``` - -**NOTE** - the actions array will replace the built-in actions too, so if you -want to have those as well as your new one, you'll need to do the following: - ```ts import { createBuiltinActions } from '@backstage/plugin-scaffolder-backend'; import { ScmIntegrations } from '@backstage/integration'; @@ -144,7 +134,6 @@ const builtInActions = createBuiltinActions({ }); const actions = [...builtInActions, createNewFileAction()]; - return await createRouter({ containerRunner, logger, diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index f95177e029..81f2622312 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -284,8 +284,8 @@ to publish to. And it can be any host that is listed in your `integrations` config in `app-config.yaml`. The `RepoUrlPicker` is a custom field that we provide part of the -`plugin-scaffolder`. It's currently not possible to create your own fields yet, -but contributions are welcome! :) +`plugin-scaffolder`. You can provide your own custom fields by +[writing your own Custom Field Extensions](./writing-custom-field-extensions.md) #### The Owner Picker diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 61d95f55c0..4148749a62 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -138,11 +138,11 @@ techdocs: # (Optional and Legacy) TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. # You don't have to specify this anymore. - requestUrl: http://localhost:7000/api/techdocs + requestUrl: http://localhost:7007/api/techdocs # (Optional and Legacy) Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware # to serve files from either a local directory or an External storage provider. # You don't have to specify this anymore. - storageUrl: http://localhost:7000/api/techdocs/static/docs + storageUrl: http://localhost:7007/api/techdocs/static/docs ``` diff --git a/docs/getting-started/contributors.md b/docs/getting-started/contributors.md index 28b062b5ca..9154eae4d1 100644 --- a/docs/getting-started/contributors.md +++ b/docs/getting-started/contributors.md @@ -43,7 +43,7 @@ the project root. Make sure you have run the above mentioned commands first. $ yarn dev ``` -This is going to start two things, the frontend (:3000) and the backend (:7000). +This is going to start two things, the frontend (:3000) and the backend (:7007). This should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index e5d15d5845..1d65f25d1b 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -36,7 +36,7 @@ This will create a new Backstage App inside the current folder. The name of the app-folder is the name that was provided when prompted.

- create app + create app

Inside that directory, it will generate all the files and folder structure diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 9632be71bf..42b334c62b 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -23,7 +23,7 @@ guide to do a repository-based installation. - Access to a Linux-based operating system, such as Linux, MacOS or [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/) -- An account with elevated rights +- An account with elevated rights to install the dependencies - `curl` or `wget` installed - Node.js Active LTS Release installed (currently v14) using one of these methods: @@ -36,15 +36,16 @@ guide to do a repository-based installation. - `yarn` [Installation](https://classic.yarnpkg.com/en/docs/install) - `docker` [installation](https://docs.docker.com/engine/install/) - `git` [installation](https://github.com/git-guides/install-git) -- If the system is not directly accessible over your network, the following - ports need to be opened: 3000, 7000 +- If the system is not directly accessible over your network the following ports + need to be opened: 3000, 7007. This is quite uncommon, unless when you're + installing in a container, VM or remote system. ### Create your Backstage App To install the Backstage Standalone app, we make use of `npx`, a tool to run -Node executables straight from the registry. Running the command below will -install Backstage. The wizard will create a subdirectory inside your current -working directory. +Node executables straight from the registry. This tool is part of your Node.js +installation. Running the command below will install Backstage. The wizard will +create a subdirectory inside your current working directory. ```bash npx @backstage/create-app @@ -57,7 +58,7 @@ The wizard will ask you SQLite option.

- Screenshot of the wizard asking for a name for the app, and a selection menu for the database. + Screenshot of the wizard asking for a name for the app, and a selection menu for the database.

### Run the Backstage app @@ -72,18 +73,27 @@ yarn dev ```

- Screenshot of the command output, with the message web pack compiled successfully. + Screenshot of the command output, with the message web pack compiled successfully.

It might take a little while, but as soon as the message `[0] webpack compiled successfully` appears, you can open a browser and directly navigate to your freshly installed Backstage portal at `http://localhost:3000`. -You can start exploring the demo immediately. +You can start exploring the demo immediately. Please note that the in-memory +database will be cleared when you restart the app, so you'll most likely want to +carry on with the database steps.

- Screenshot of the Backstage portal. + Screenshot of the Backstage portal.

+The most common next steps are to move to a persistent database, configure +authentication, and add a plugin: + +- [Switching from SQLite to PostgresQL](https://backstage.io/docs/tutorials/switching-sqlite-postgres) +- [Setting up Authentication](https://backstage.io/docs/auth/) +- [Adding a plugin](https://backstage.io/docs/getting-started/configure-app-with-plugins) + Congratulations! That should be it. Let us know how it went: [on discord](https://discord.gg/EBHEGzX), file issues for any [feature](https://github.com/backstage/backstage/issues/new?labels=help+wanted&template=feature_template.md) @@ -93,10 +103,3 @@ or [bugs](https://github.com/backstage/backstage/issues/new?labels=bug&template=bug_template.md) you have, and feel free to [contribute](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md)! - -The most common next steps are to configure Backstage, add a plugin and moving -to a more persistent database: - -- [Setting up Authentication](https://backstage.io/docs/auth/) -- [Switching from SQLite to PostgresQL](https://backstage.io/docs/tutorials/switching-sqlite-postgres) -- [Adding a plugin](https://backstage.io/docs/getting-started/configure-app-with-plugins) diff --git a/docs/getting-started/project-structure.md b/docs/getting-started/project-structure.md index 687743f99d..a0390571b8 100644 --- a/docs/getting-started/project-structure.md +++ b/docs/getting-started/project-structure.md @@ -166,13 +166,9 @@ are separated out into their own folder, see further down. plugin and [techdocs-cli](https://github.com/backstage/techdocs-cli). - [`test-utils/`](https://github.com/backstage/backstage/tree/master/packages/test-utils) - - This package contains more general purpose testing facilities for testing a + This package contains general purpose testing facilities for testing a Backstage App or its plugins. -- [`test-utils-core/`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core) - - This package contains specific testing facilities used when testing Backstage - core internals. - - [`theme/`](https://github.com/backstage/backstage/tree/master/packages/theme) - Holds the Backstage Theme. diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md index 873c26a099..daad7bf195 100644 --- a/docs/getting-started/running-backstage-locally.md +++ b/docs/getting-started/running-backstage-locally.md @@ -70,7 +70,7 @@ cd packages/backend yarn start ``` -That starts up a backend instance on port 7000. +That starts up a backend instance on port 7007. In the other window, we will then launch the frontend. This command is run from the project root, not inside the backend directory. diff --git a/docs/integrations/azure/discovery.md b/docs/integrations/azure/discovery.md new file mode 100644 index 0000000000..ad452f47af --- /dev/null +++ b/docs/integrations/azure/discovery.md @@ -0,0 +1,49 @@ +--- +id: discovery +title: Azure DevOps Discovery +sidebar_label: Discovery +# prettier-ignore +description: Automatically discovering catalog entities from repositories in an Azure DevOps organization +--- + +The Azure DevOps integration has a special discovery processor for discovering +catalog entities within an Azure DevOps. The processor will crawl the Azure +DevOps 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 `AZURE_TOKEN`. Then you can add a location target +to the catalog configuration: + +```yaml +catalog: + locations: + # Scan all repositories for a catalog-info.yaml in the root of the default branch + - type: azure-discovery + target: https://dev.azure.com/myorg/myproject + # Or use a custom pattern for a subset of all repositories with default repository + - type: azure-discovery + target: https://dev.azure.com/myorg/myproject/_git/service-* + # Or use a custom file format and location + - type: azure-discovery + target: https://dev.azure.com/myorg/myproject/_git/*?path=/src/*/catalog-info.yaml +``` + +Note the `azure-discovery` type, as this is not a regular `url` processor. + +When using a custom pattern, the target is composed of five parts: + +- The base instance URL, `https://dev.azure.com` in this case +- The organization name which is required, `myorg` in this case +- The project name which is required, `myproject` in this case +- The repository blob to scan, which accepts \* wildcard tokens and must be + added after `_git/`. This can simply be `*` to scan all repositories in the + project. +- The path within each repository to find the catalog YAML file. This will + usually be `/catalog-info.yaml`, `/src/*/catalog-info.yaml` or a similar + variation for catalog files stored in the root directory of each repository. + +_Note:_ the path parameter follows the same rules as the search on Azure DevOps +web interface. For more details visit the +[official search documentation](https://docs.microsoft.com/en-us/azure/devops/project/search/get-started-search?view=azure-devops) diff --git a/docs/local-dev/cli-commands.md b/docs/local-dev/cli-commands.md index 18fdef892a..318b37f34e 100644 --- a/docs/local-dev/cli-commands.md +++ b/docs/local-dev/cli-commands.md @@ -165,6 +165,7 @@ common example being the `migrations` directory. Usage: backstage-cli backend:build [options] Options: + --minify Minify the generated code -h, --help display help for command ``` @@ -371,6 +372,7 @@ the monorepo. Usage: backstage-cli plugin:build [options] Options: + --minify Minify the generated code -h, --help display help for command ``` diff --git a/docs/openapi/definitions/auth.yaml b/docs/openapi/definitions/auth.yaml index 411ce69253..032d89523d 100644 --- a/docs/openapi/definitions/auth.yaml +++ b/docs/openapi/definitions/auth.yaml @@ -21,7 +21,7 @@ externalDocs: description: Backstage official documentation url: https://github.com/backstage/backstage/blob/master/docs/README.md servers: - - url: http://localhost:7000/api/auth/ + - url: http://localhost:7007/api/auth/ tags: - name: provider description: List of endpoints per provider diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md index a79bcd6e11..a79989412d 100644 --- a/docs/overview/stability-index.md +++ b/docs/overview/stability-index.md @@ -27,7 +27,7 @@ point building on top of the previous one: and the new APIs can be used in parallel. This deprecation must have been released for at least two weeks before the deprecated API is removed in a minor version bump. -- **3** - The time limit for the deprecation is 3 months instead of two days. +- **3** - The time limit for the deprecation is 3 months instead of two weeks. TL;DR: @@ -175,15 +175,6 @@ Utilities for writing tests for Backstage plugins and apps. Stability: `2` -### `test-utils-core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/test-utils-core/) - -Internal testing utilities that are separated out for usage in -@backstage/core-app-api and @backstage/core-plugin-api. All exports are -re-exported by @backstage/test-utils. This package should not be depended on -directly. - -Stability: See @backstage/test-utils - ### `theme` [GitHub](https://github.com/backstage/backstage/tree/master/packages/theme/) The core Backstage MUI theme along with customization utilities. diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md index 7adf2650df..ace9a89968 100644 --- a/docs/plugins/analytics.md +++ b/docs/plugins/analytics.md @@ -261,10 +261,13 @@ analytics events captured. Use it like this: ```tsx -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; -import { analyticsApiRef } from '@backstage/core-plugin-api'; -import { MockAnalyticsApi, wrapInTestApp } from '@backstage/test-utils'; import { render, fireEvent, waitFor } from '@testing-library/react'; +import { analyticsApiRef } from '@backstage/core-plugin-api'; +import { + MockAnalyticsApi, + TestApiProvider, + wrapInTestApp, +} from '@backstage/test-utils'; describe('SomeComponent', () => { it('should capture event on click', () => { @@ -274,9 +277,9 @@ describe('SomeComponent', () => { // Render the component being tested const { getByText } = render( wrapInTestApp( - + - , + , ), ); diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md index 1d728b76aa..5ff0e5a62f 100644 --- a/docs/plugins/backend-plugin.md +++ b/docs/plugins/backend-plugin.md @@ -44,11 +44,11 @@ cd plugins/carmen-backend yarn start ``` -This will think for a bit, and then say `Listening on :7000`. In a different +This will think for a bit, and then say `Listening on :7007`. In a different terminal window, now run ```sh -curl localhost:7000/carmen/health +curl localhost:7007/carmen/health ``` This should return `{"status":"ok"}`. Success! Press `Ctrl + c` to kill it @@ -107,7 +107,7 @@ root), you should be able to fetch data from it. ```sh # Note the extra /api here -curl localhost:7000/api/carmen/health +curl localhost:7007/api/carmen/health ``` This should return `{"status":"ok"}` like before. Success! diff --git a/docs/tutorials/backend-to-backend-auth.md b/docs/tutorials/backend-to-backend-auth.md new file mode 100644 index 0000000000..3cb72a4827 --- /dev/null +++ b/docs/tutorials/backend-to-backend-auth.md @@ -0,0 +1,68 @@ +--- +id: backend-to-backend-auth +title: Backend-to-Backend Authentication +description: + Guide for authenticating API requests between Backstage plugin backends +--- + +This tutorial describes the steps needed to handle _backend-to-backend +authentication_, which allows plugin backends to determine whether a given +request originates from a legitimate Backstage backend by verifying a token +signed with a shared secret. This system has limited use for now, but will be +needed to support the upcoming framework for permissions and authorization (see +[the PRFC on the topic](https://github.com/backstage/backstage/pull/7761) for +more details). + +Backends have no concept of a Backstage identity, so instead they use a token +generated using a shared key stored in config. You can generate a unique key for +your app in a terminal, and set the `BACKEND_SECRET` environment variable to the +resulting value. + +```bash +node -p 'require("crypto").randomBytes(24).toString("base64")' +``` + +Requests originating from a backend plugin can be authenticated by decorating +them with a backend token. Backend tokens can be generated using a +`TokenManager`, which can be passed to plugin backends via the +`PluginEnvironment`. The `TokenManager` provided in new Backstage instances +generated by `create-app` is a stub, which returns empty tokens and accepts any +input string as valid. To enable backend-to-backend authentication, you'll need +to instantiate a new one using the secret from your config instead: + +```diff +// packages/backend/src/index.ts + +function makeCreateEnv(config: Config) { + const root = getRootLogger(); + const reader = UrlReaders.default({ logger: root, config }); + const discovery = SingleHostDiscovery.fromConfig(config); + + root.info(`Created UrlReader ${reader}`); + + const cacheManager = CacheManager.fromConfig(config); + const databaseManager = DatabaseManager.fromConfig(config); +- const tokenManager = ServerTokenManager.noop(); ++ const tokenManager = ServerTokenManager.fromConfig(config); +``` + +With this `tokenManager`, you can then generate a server token for requests: + +```typescript +const { token } = await this.tokenManager.getToken(); + +const response = await fetch(pluginBackendApiUrl, { + method: 'GET', + headers: { + ...headers, + Authorization: `Bearer ${token}`, + }, +}); +``` + +You can use the same `tokenManager` to authenticate tokens supplied on incoming +requests: + +```typescript +await tokenManager.authenticate(token); // throws if token is invalid +``` diff --git a/lerna.json b/lerna.json index 322929db1d..4621689664 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "packages": ["packages/*", "plugins/*"], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.1.0" + "version": "0.0.0" } diff --git a/microsite/blog/2020-05-22-phase-2-service-catalog.md b/microsite/blog/2020-05-22-phase-2-service-catalog.md index 520a2a5f10..2df8ce84f3 100644 --- a/microsite/blog/2020-05-22-phase-2-service-catalog.md +++ b/microsite/blog/2020-05-22-phase-2-service-catalog.md @@ -1,5 +1,5 @@ --- -title: Starting Phase 2: The Service Catalog +title: 'Starting Phase 2: The Service Catalog' author: Stefan Ålund, Spotify authorURL: http://twitter.com/stalund authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaace78c3009b28e917-user-full-200-130.generated_400x400.jpg diff --git a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md index afda20e499..0bf8d7e581 100644 --- a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md +++ b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md @@ -7,7 +7,7 @@ authorURL: https://twitter.com/stalund **TL;DR** Today we are announcing a new Backstage feature: Software Templates. Simplify setup, standardize tooling, and deploy with the click of a button. Using automated templates, your engineers can spin up a new microservice, website, or other software component with your organization’s best practices built-in, right from the start. diff --git a/microsite/blog/2020-09-08-announcing-tech-docs.md b/microsite/blog/2020-09-08-announcing-tech-docs.md index 98fbad57c5..f9d7359526 100644 --- a/microsite/blog/2020-09-08-announcing-tech-docs.md +++ b/microsite/blog/2020-09-08-announcing-tech-docs.md @@ -1,5 +1,5 @@ --- -title: Announcing TechDocs: Spotify’s docs-like-code plugin for Backstage +title: 'Announcing TechDocs: Spotify’s docs-like-code plugin for Backstage' author: Gary Niemen, Spotify authorURL: https://github.com/garyniemen --- diff --git a/microsite/blog/2020-10-22-cost-insights-plugin.md b/microsite/blog/2020-10-22-cost-insights-plugin.md index d265698694..139e9a7ce9 100644 --- a/microsite/blog/2020-10-22-cost-insights-plugin.md +++ b/microsite/blog/2020-10-22-cost-insights-plugin.md @@ -1,5 +1,5 @@ --- -title: New Cost Insights plugin: The engineer’s solution to taming cloud costs +title: 'New Cost Insights plugin: The engineer’s solution to taming cloud costs' author: Janisa Anandamohan, Spotify authorURL: https://twitter.com/janisa_a --- diff --git a/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md index a0d11a580f..9b8c4637bd 100644 --- a/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md +++ b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md @@ -1,5 +1,5 @@ --- -title: New Backstage feature: Kubernetes for Service owners +title: 'New Backstage feature: Kubernetes for Service owners' author: Matthew Clarke, Spotify authorURL: https://github.com/mclarke47 --- diff --git a/microsite/blog/2021-06-24-announcing-backstage-search-platform.md b/microsite/blog/2021-06-24-announcing-backstage-search-platform.md index 0a4ae572c2..6e6c9b7819 100644 --- a/microsite/blog/2021-06-24-announcing-backstage-search-platform.md +++ b/microsite/blog/2021-06-24-announcing-backstage-search-platform.md @@ -1,5 +1,5 @@ --- -title: Announcing the Backstage Search platform: a customizable search tool built just for you +title: 'Announcing the Backstage Search platform: a customizable search tool built just for you' author: Emma Indal, Spotify authorURL: https://www.linkedin.com/in/emma-indal --- diff --git a/microsite/data/plugins/splunk-on-call.yaml b/microsite/data/plugins/splunk-on-call.yaml index 15ceb86a1b..bb0e25111a 100644 --- a/microsite/data/plugins/splunk-on-call.yaml +++ b/microsite/data/plugins/splunk-on-call.yaml @@ -1,7 +1,7 @@ --- title: Splunk On-Call -author: Spotify -authorUrl: https://github.com/spotify +author: ayshiff +authorUrl: https://github.com/ayshiff category: Monitoring description: Splunk On-Call offers a simple way to identify incidents and escalation policies. documentation: https://github.com/backstage/backstage/tree/master/plugins/splunk-on-call diff --git a/microsite/package.json b/microsite/package.json index a2a2fd98de..a24de8935d 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -19,7 +19,7 @@ "@spotify/prettier-config": "^12.0.0", "docusaurus": "^2.0.0-alpha.70", "js-yaml": "^4.1.0", - "prettier": "^2.4.1", + "prettier": "^2.5.0", "yarn-lock-check": "^1.0.5" }, "prettier": "@spotify/prettier-config" diff --git a/microsite/sidebars.json b/microsite/sidebars.json index a24d5897ab..89eb282d14 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -125,7 +125,11 @@ { "type": "subcategory", "label": "Azure", - "ids": ["integrations/azure/locations", "integrations/azure/org"] + "ids": [ + "integrations/azure/locations", + "integrations/azure/discovery", + "integrations/azure/org" + ] }, { "type": "subcategory", diff --git a/microsite/yarn.lock b/microsite/yarn.lock index f3834ac734..9946f4914d 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -1624,15 +1624,6 @@ cli-width@^2.0.0: resolved "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48" integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw== -clipboard@^2.0.0: - version "2.0.6" - resolved "https://registry.npmjs.org/clipboard/-/clipboard-2.0.6.tgz#52921296eec0fdf77ead1749421b21c968647376" - integrity sha512-g5zbiixBRk/wyKakSwCKd7vQXDjFnAMGHoEyBogG/bw9kTD9GvdAvaoRR1ALcEzt3pVKxZR0pViekPMIS0QyGg== - dependencies: - good-listener "^1.2.2" - select "^1.1.2" - tiny-emitter "^2.0.0" - clone-response@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" @@ -1687,9 +1678,9 @@ color-name@^1.0.0, color-name@~1.1.4: integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== color-string@^1.5.4: - version "1.5.4" - resolved "https://registry.npmjs.org/color-string/-/color-string-1.5.4.tgz#dd51cd25cfee953d138fe4002372cc3d0e504cb6" - integrity sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw== + version "1.6.0" + resolved "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312" + integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA== dependencies: color-name "^1.0.0" simple-swizzle "^0.2.2" @@ -2158,11 +2149,6 @@ delayed-stream@~1.0.0: resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -delegate@^3.1.2: - version "3.2.0" - resolved "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" - integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== - depd@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -3097,13 +3083,6 @@ globule@^1.0.0: lodash "~4.17.10" minimatch "~3.0.2" -good-listener@^1.2.2: - version "1.2.2" - resolved "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" - integrity sha1-1TswzfkxPf+33JoNR3CWqm0UXFA= - dependencies: - delegate "^3.1.2" - got@^7.0.0: version "7.1.0" resolved "https://registry.npmjs.org/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a" @@ -3276,9 +3255,9 @@ highlight.js@^9.16.2: integrity sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA== hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== hsl-regex@^1.0.0: version "1.0.0" @@ -4815,9 +4794,9 @@ path-key@^2.0.0, path-key@^2.0.1: integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= path-parse@^1.0.6: - version "1.0.6" - resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" - integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + version "1.0.7" + resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== path-to-regexp@0.1.7: version "0.1.7" @@ -4850,6 +4829,11 @@ performance-now@^2.1.0: resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +picocolors@^0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f" + integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA== + pify@^2.0.0, pify@^2.2.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -5189,13 +5173,12 @@ postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.23, postcss@^7.0.27, postcss@^7.0.32: - version "7.0.35" - resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" - integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== + version "7.0.39" + resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz#9624375d965630e2e1f2c02a935c82a59cb48309" + integrity sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA== dependencies: - chalk "^2.4.2" + picocolors "^0.2.1" source-map "^0.6.1" - supports-color "^6.1.0" prepend-http@^1.0.1: version "1.0.4" @@ -5207,17 +5190,15 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= -prettier@^2.4.1: - version "2.4.1" - resolved "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz#671e11c89c14a4cfc876ce564106c4a6726c9f5c" - integrity sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA== +prettier@^2.5.0: + version "2.5.0" + resolved "https://registry.npmjs.org/prettier/-/prettier-2.5.0.tgz#a6370e2d4594e093270419d9cc47f7670488f893" + integrity sha512-FM/zAKgWTxj40rH03VxzIPdXmj39SwSjwG0heUcNFwI+EMZJnY93yAiKXM3dObIKAM5TA88werc8T/EwhB45eg== prismjs@^1.22.0: - version "1.23.0" - resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33" - integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA== - optionalDependencies: - clipboard "^2.0.0" + version "1.25.0" + resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz#6f822df1bdad965734b310b315a23315cf999756" + integrity sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg== process-nextick-args@~2.0.0: version "2.0.1" @@ -5709,11 +5690,6 @@ seek-bzip@^1.0.5: dependencies: commander "^2.8.1" -select@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" - integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= - semver-regex@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz#a93c2c5844539a770233379107b38c7b4ac9d338" @@ -5766,9 +5742,9 @@ serve-static@1.14.1: send "0.17.1" set-getter@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/set-getter/-/set-getter-0.1.0.tgz#d769c182c9d5a51f409145f2fba82e5e86e80376" - integrity sha1-12nBgsnVpR9AkUXy+6guXoboA3Y= + version "0.1.1" + resolved "https://registry.npmjs.org/set-getter/-/set-getter-0.1.1.tgz#a3110e1b461d31a9cfc8c5c9ee2e9737ad447102" + integrity sha512-9sVWOy+gthr+0G9DzqqLaYNA7+5OKkSmcqjL9cBpDEaZrr3ShQlyX2cZ/O/ozE41oxn/Tt0LGEM/w4Rub3A3gw== dependencies: to-object-path "^0.3.0" @@ -6150,13 +6126,6 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" - integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== - dependencies: - has-flag "^3.0.0" - supports-color@^7.1.0: version "7.2.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -6250,11 +6219,6 @@ timsort@^0.3.0: resolved "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= -tiny-emitter@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" - integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== - tiny-lr@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab" @@ -6502,9 +6466,9 @@ url-parse-lax@^3.0.0: prepend-http "^2.0.0" url-parse@^1.4.3: - version "1.4.7" - resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" - integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== + version "1.5.3" + resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862" + integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ== dependencies: querystringify "^2.1.1" requires-port "^1.0.0" diff --git a/mkdocs.yml b/mkdocs.yml index 48df25afef..2eed377819 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -88,6 +88,7 @@ nav: - Discovery: 'integrations/aws-s3/discovery.md' - Azure: - Locations: 'integrations/azure/locations.md' + - Discovery: 'integrations/azure/discovery.md' - Org Data: 'integrations/azure/org.md' - Bitbucket: - Locations: 'integrations/bitbucket/locations.md' diff --git a/package.json b/package.json index 16ba8461ac..2d63f92d3a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "prettier:check": "prettier --check .", "lerna": "lerna", "storybook": "yarn workspace storybook start", + "snyk:test": "npx snyk test --yarn-workspaces --strict-out-of-sync=false", + "snyk:test:package": "yarn snyk:test --include", "build-storybook": "yarn workspace storybook build-storybook", "techdocs-cli": "node scripts/techdocs-cli.js", "techdocs-cli:dev": "cross-env TECHDOCS_CLI_DEV_MODE=true node scripts/techdocs-cli.js", @@ -61,7 +63,8 @@ "devDependencies": { "@changesets/cli": "^2.14.0", "@octokit/rest": "^18.12.0", - "@spotify/prettier-config": "^11.0.0", + "@spotify/prettier-config": "^12.0.0", + "@types/node": "^14.14.32", "@types/webpack": "^5.28.0", "command-exists": "^1.2.9", "cross-env": "^7.0.0", diff --git a/packages/app-defaults/CHANGELOG.md b/packages/app-defaults/CHANGELOG.md new file mode 100644 index 0000000000..b2b50ae48b --- /dev/null +++ b/packages/app-defaults/CHANGELOG.md @@ -0,0 +1,10 @@ +# @backstage/app-defaults + +## 0.1.1 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 diff --git a/packages/app-defaults/package.json b/packages/app-defaults/package.json index 9438966b3b..695d457455 100644 --- a/packages/app-defaults/package.json +++ b/packages/app-defaults/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/app-defaults", "description": "Provides the default wiring of a Backstage App", - "version": "0.1.0", + "version": "0.1.1", "private": false, "publishConfig": { "access": "public", @@ -29,18 +29,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-app-api": "^0.1.20", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-app-api": "^0.1.24", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "react": "^16.12.0", "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@types/jest": "^26.0.7", diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 1dee41af56..2d006791e9 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,70 @@ # example-app +## 0.2.55 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-azure-devops@0.1.5 + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/plugin-user-settings@0.3.12 + - @backstage/plugin-api-docs@0.6.16 + - @backstage/cli@0.10.0 + - @backstage/plugin-circleci@0.2.30 + - @backstage/core-plugin-api@0.2.2 + - @backstage/plugin-search@0.5.0 + - @backstage/plugin-tech-insights@0.1.0 + - @backstage/core-app-api@0.1.24 + - @backstage/plugin-kubernetes@0.4.22 + - @backstage/plugin-scaffolder@0.11.13 + - @backstage/plugin-techdocs@0.12.8 + +## 0.2.53 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog@0.7.3 + - @backstage/plugin-cost-insights@0.11.11 + - @backstage/cli@0.9.0 + - @backstage/plugin-gcp-projects@0.3.9 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-scaffolder@0.11.11 + - @backstage/plugin-api-docs@0.6.14 + - @backstage/plugin-azure-devops@0.1.4 + - @backstage/plugin-badges@0.2.14 + - @backstage/plugin-catalog-graph@0.2.2 + - @backstage/plugin-catalog-import@0.7.4 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/plugin-circleci@0.2.29 + - @backstage/plugin-cloudbuild@0.2.28 + - @backstage/plugin-code-coverage@0.1.18 + - @backstage/plugin-explore@0.3.21 + - @backstage/plugin-github-actions@0.4.24 + - @backstage/plugin-home@0.4.6 + - @backstage/plugin-jenkins@0.5.12 + - @backstage/plugin-kafka@0.2.21 + - @backstage/plugin-kubernetes@0.4.20 + - @backstage/plugin-lighthouse@0.2.30 + - @backstage/plugin-newrelic@0.3.9 + - @backstage/plugin-pagerduty@0.3.18 + - @backstage/plugin-rollbar@0.3.19 + - @backstage/plugin-search@0.4.18 + - @backstage/plugin-sentry@0.3.29 + - @backstage/plugin-tech-radar@0.4.12 + - @backstage/plugin-techdocs@0.12.6 + - @backstage/plugin-todo@0.1.15 + - @backstage/plugin-user-settings@0.3.11 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 + - @backstage/plugin-shortcuts@0.1.14 + - @backstage/app-defaults@0.1.1 + - @backstage/integration-react@0.1.14 + - @backstage/plugin-graphiql@0.2.21 + - @backstage/plugin-org@0.3.28 + ## 0.2.51 ### Patch Changes diff --git a/packages/app/cypress/integration/components/search/SearchPage.js b/packages/app/cypress/integration/components/search/SearchPage.js index 6a18d16198..11f31cfed3 100644 --- a/packages/app/cypress/integration/components/search/SearchPage.js +++ b/packages/app/cypress/integration/components/search/SearchPage.js @@ -14,7 +14,7 @@ * limitations under the License. */ -const API_ENDPOINT = 'http://localhost:7000/api/search/query'; +const API_ENDPOINT = 'http://localhost:7007/api/search/query'; describe('SearchPage', () => { describe('Given a search context with a term, results, and filter values', () => { diff --git a/packages/app/package.json b/packages/app/package.json index 9a1fb108f6..36b811c27d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,50 +1,51 @@ { "name": "example-app", - "version": "0.2.51", + "version": "0.2.55", "private": true, "bundled": true, "dependencies": { - "@backstage/app-defaults": "^0.1.0", - "@backstage/catalog-model": "^0.9.5", - "@backstage/cli": "^0.8.0", - "@backstage/core-app-api": "^0.1.18", - "@backstage/core-components": "^0.7.1", - "@backstage/core-plugin-api": "^0.1.11", - "@backstage/integration-react": "^0.1.12", - "@backstage/plugin-api-docs": "^0.6.12", - "@backstage/plugin-azure-devops": "^0.1.1", - "@backstage/plugin-badges": "^0.2.13", - "@backstage/plugin-catalog": "^0.7.2", - "@backstage/plugin-catalog-graph": "^0.2.1", - "@backstage/plugin-catalog-import": "^0.7.3", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/plugin-circleci": "^0.2.27", - "@backstage/plugin-cloudbuild": "^0.2.27", - "@backstage/plugin-code-coverage": "^0.1.15", - "@backstage/plugin-cost-insights": "^0.11.10", - "@backstage/plugin-explore": "^0.3.20", - "@backstage/plugin-gcp-projects": "^0.3.8", - "@backstage/plugin-github-actions": "^0.4.22", - "@backstage/plugin-graphiql": "^0.2.20", - "@backstage/plugin-home": "^0.4.4", - "@backstage/plugin-jenkins": "^0.5.11", - "@backstage/plugin-kafka": "^0.2.19", - "@backstage/plugin-kubernetes": "^0.4.17", - "@backstage/plugin-lighthouse": "^0.2.29", - "@backstage/plugin-newrelic": "^0.3.8", - "@backstage/plugin-org": "^0.3.27", - "@backstage/plugin-pagerduty": "0.3.17", - "@backstage/plugin-rollbar": "^0.3.18", - "@backstage/plugin-scaffolder": "^0.11.8", - "@backstage/plugin-search": "^0.4.15", - "@backstage/plugin-sentry": "^0.3.26", - "@backstage/plugin-shortcuts": "^0.1.12", - "@backstage/plugin-tech-radar": "^0.4.11", - "@backstage/plugin-techdocs": "^0.12.3", - "@backstage/plugin-todo": "^0.1.14", - "@backstage/plugin-user-settings": "^0.3.10", + "@backstage/app-defaults": "^0.1.1", + "@backstage/catalog-model": "^0.9.7", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-api-docs": "^0.6.16", + "@backstage/plugin-azure-devops": "^0.1.5", + "@backstage/plugin-badges": "^0.2.14", + "@backstage/plugin-catalog": "^0.7.3", + "@backstage/plugin-catalog-graph": "^0.2.2", + "@backstage/plugin-catalog-import": "^0.7.4", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/plugin-circleci": "^0.2.30", + "@backstage/plugin-cloudbuild": "^0.2.28", + "@backstage/plugin-code-coverage": "^0.1.18", + "@backstage/plugin-cost-insights": "^0.11.11", + "@backstage/plugin-explore": "^0.3.21", + "@backstage/plugin-gcp-projects": "^0.3.9", + "@backstage/plugin-github-actions": "^0.4.24", + "@backstage/plugin-graphiql": "^0.2.21", + "@backstage/plugin-home": "^0.4.6", + "@backstage/plugin-jenkins": "^0.5.12", + "@backstage/plugin-kafka": "^0.2.21", + "@backstage/plugin-kubernetes": "^0.4.22", + "@backstage/plugin-lighthouse": "^0.2.30", + "@backstage/plugin-newrelic": "^0.3.9", + "@backstage/plugin-org": "^0.3.28", + "@backstage/plugin-pagerduty": "0.3.18", + "@backstage/plugin-rollbar": "^0.3.19", + "@backstage/plugin-scaffolder": "^0.11.13", + "@backstage/plugin-search": "^0.5.0", + "@backstage/plugin-sentry": "^0.3.29", + "@backstage/plugin-shortcuts": "^0.1.14", + "@backstage/plugin-tech-radar": "^0.4.12", + "@backstage/plugin-techdocs": "^0.12.8", + "@backstage/plugin-todo": "^0.1.15", + "@backstage/plugin-user-settings": "^0.3.12", "@backstage/search-common": "^0.2.0", - "@backstage/theme": "^0.2.11", + "@backstage/plugin-tech-insights": "^0.1.0", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -64,9 +65,9 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/test-utils": "^0.1.19", - "@rjsf/core": "^3.0.0", - "@testing-library/cypress": "^7.0.1", + "@backstage/test-utils": "^0.1.22", + "@rjsf/core": "^3.2.1", + "@testing-library/cypress": "^8.0.2", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index aa6781ce61..8c7cefeb2c 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -27,14 +27,14 @@ describe('App', () => { data: { app: { title: 'Test', - support: { url: 'http://localhost:7000/support' }, + support: { url: 'http://localhost:7007/support' }, }, - backend: { baseUrl: 'http://localhost:7000' }, + backend: { baseUrl: 'http://localhost:7007' }, lighthouse: { baseUrl: 'http://localhost:3003', }, techdocs: { - storageUrl: 'http://localhost:7000/api/techdocs/static/docs', + storageUrl: 'http://localhost:7007/api/techdocs/static/docs', }, }, context: 'test', diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 401665b62e..7e5d3db926 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -34,6 +34,7 @@ import { SignInPage, } from '@backstage/core-components'; import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs'; +import { AzurePullRequestsPage } from '@backstage/plugin-azure-devops'; import { CatalogEntityPage, CatalogIndexPage, @@ -175,7 +176,20 @@ const routes = ( > {techDocsPage} - }> + + entity?.metadata?.tags?.includes('recommended') ?? false, + }, + ]} + /> + } + > @@ -203,6 +217,7 @@ const routes = ( element={} /> } /> + } /> ); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 87fa8c8470..57f1fa536f 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -14,6 +14,31 @@ * limitations under the License. */ +import React, { useContext, PropsWithChildren } from 'react'; +import { Link, makeStyles } from '@material-ui/core'; +import HomeIcon from '@material-ui/icons/Home'; +import ExtensionIcon from '@material-ui/icons/Extension'; +import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; +import MapIcon from '@material-ui/icons/MyLocation'; +import LayersIcon from '@material-ui/icons/Layers'; +import LibraryBooks from '@material-ui/icons/LibraryBooks'; +import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import SearchIcon from '@material-ui/icons/Search'; +import MenuIcon from '@material-ui/icons/Menu'; +import MoneyIcon from '@material-ui/icons/MonetizationOn'; +import LogoFull from './LogoFull'; +import LogoIcon from './LogoIcon'; +import { NavLink } from 'react-router-dom'; +import { GraphiQLIcon } from '@backstage/plugin-graphiql'; +import { + Settings as SidebarSettings, + UserSettingsSignInAvatar, +} from '@backstage/plugin-user-settings'; +import { + SidebarSearchModal, + SearchContextProvider, +} from '@backstage/plugin-search'; +import { Shortcuts } from '@backstage/plugin-shortcuts'; import { Sidebar, sidebarConfig, @@ -25,28 +50,6 @@ import { SidebarScrollWrapper, SidebarSpace, } from '@backstage/core-components'; -import { GraphiQLIcon } from '@backstage/plugin-graphiql'; -import { SidebarSearchModal } from '@backstage/plugin-search'; -import { Shortcuts } from '@backstage/plugin-shortcuts'; -import { - Settings as SidebarSettings, - UserSettingsSignInAvatar, -} from '@backstage/plugin-user-settings'; -import { Link, makeStyles } from '@material-ui/core'; -import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; -import ExtensionIcon from '@material-ui/icons/Extension'; -import HomeIcon from '@material-ui/icons/Home'; -import LayersIcon from '@material-ui/icons/Layers'; -import LibraryBooks from '@material-ui/icons/LibraryBooks'; -import MenuIcon from '@material-ui/icons/Menu'; -import MoneyIcon from '@material-ui/icons/MonetizationOn'; -import MapIcon from '@material-ui/icons/MyLocation'; -import SearchIcon from '@material-ui/icons/Search'; -import React, { PropsWithChildren, useContext } from 'react'; -import { NavLink } from 'react-router-dom'; -import LogoFull from './LogoFull'; -import LogoIcon from './LogoIcon'; const useSidebarLogoStyles = makeStyles({ root: { @@ -86,7 +89,9 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( } to="/search"> - + + + }> diff --git a/packages/app/src/components/catalog/EntityPage.test.tsx b/packages/app/src/components/catalog/EntityPage.test.tsx index 089cb9c7cf..a6878cd28b 100644 --- a/packages/app/src/components/catalog/EntityPage.test.tsx +++ b/packages/app/src/components/catalog/EntityPage.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { EntityLayout } from '@backstage/plugin-catalog'; import { DefaultStarredEntitiesApi, @@ -22,7 +21,11 @@ import { starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; import { githubActionsApiRef } from '@backstage/plugin-github-actions'; -import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import { + MockStorageApi, + renderInTestApp, + TestApiProvider, +} from '@backstage/test-utils'; import React from 'react'; import { cicdContent } from './EntityPage'; @@ -45,22 +48,22 @@ describe('EntityPage Test', () => { const mockedApi = { listWorkflowRuns: jest.fn().mockResolvedValue([]), - getWorkflow: jest.fn(), - getWorkflowRun: jest.fn(), - reRunWorkflow: jest.fn(), - listJobsForWorkflowRun: jest.fn(), - downloadJobLogsForWorkflowRun: jest.fn(), - } as jest.Mocked; - - const apis = ApiRegistry.with(githubActionsApiRef, mockedApi).with( - starredEntitiesApiRef, - new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }), - ); + }; describe('cicdContent', () => { it('Should render GitHub Actions View', async () => { const rendered = await renderInTestApp( - + @@ -68,7 +71,7 @@ describe('EntityPage Test', () => { - , + , ); expect(rendered.getByText('ExampleComponent')).toBeInTheDocument(); diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 3fe027ddad..3b9b381380 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -35,6 +35,7 @@ import { } from '@backstage/plugin-api-docs'; import { EntityAzurePipelinesContent, + EntityAzurePullRequestsContent, isAzureDevOpsAvailable, } from '@backstage/plugin-azure-devops'; import { EntityBadgesDialog } from '@backstage/plugin-badges'; @@ -264,6 +265,18 @@ const errorsContent = ( ); +const pullRequestsContent = ( + + + + + + + + + +); + const overviewContent = ( {entityWarningContent} @@ -368,7 +381,7 @@ const serviceEntityPage = ( - + {pullRequestsContent} @@ -427,7 +440,7 @@ const websiteEntityPage = ( - + {pullRequestsContent} diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index f3464917aa..b2bf629bed 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,61 @@ # @backstage/backend-common +## 0.9.12 + +### Patch Changes + +- 905dd952ac: Create a `TokenManager` interface and `ServerTokenManager` implementation to generate and validate server tokens for authenticated backend-to-backend API requests. +- 6b500622d5: Move to using node-fetch internally instead of cross-fetch +- 54989b671d: Fixed a potential crash in the log redaction code +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/config-loader@0.8.1 + +## 0.9.11 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/errors@0.1.5 + +## 0.9.10 + +### Patch Changes + +- d7c1e0e34a: Added the `isDatabaseConflictError` function. +- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers` +- 1e99c73c75: Update internal usage of `configLoader.loadConfig` that now returns an object instead of an array of configs. +- 1daada3a06: Paths can be specified in backend.reading.allow to further restrict allowed targets +- 7ad9a07b27: Adding config prop `pluginDivisionMode` to allow plugins using the `pg` client to create their own management schemas in the db. This allows `pg` client plugins to work in separate schemas in the same db. +- 01f74aa878: Add `AbortSignal` support to `UrlReader` +- a8732a1200: Make sure that server builder `start()` propagates errors (such as failing to bind to the required port) properly and doesn't resolve the promise prematurely. + + After this change, the backend logger will be able to actually capture the error as it happens: + + ``` + 2021-11-11T10:54:21.334Z backstage info Initializing http server + 2021-11-11T10:54:21.335Z backstage error listen EADDRINUSE: address already in use :::7000 code=EADDRINUSE errno=-48 syscall=listen address=:: port=7000 + ``` + +- 26b5da1c1a: Do not redact empty or one-character strings. These imply that it's just a test or local dev, and unnecessarily ruin the log output. +- Updated dependencies + - @backstage/config-loader@0.8.0 + - @backstage/cli-common@0.1.6 + ## 0.9.9 ### Patch Changes diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index e3119e78a8..9c66c0573e 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -526,6 +526,20 @@ export type SearchResponseFile = { content(): Promise; }; +// @public +export class ServerTokenManager implements TokenManager { + // (undocumented) + authenticate(token: string): Promise; + // (undocumented) + static fromConfig(config: Config): ServerTokenManager; + // (undocumented) + getToken(): Promise<{ + token: string; + }>; + // (undocumented) + static noop(): TokenManager; +} + // @public (undocumented) export type ServiceBuilder = { loadConfig(config: Config): ServiceBuilder; @@ -583,6 +597,16 @@ export interface StatusCheckHandlerOptions { statusCheck?: StatusCheck; } +// @public +export interface TokenManager { + // (undocumented) + authenticate: (token: string) => Promise; + // (undocumented) + getToken: () => Promise<{ + token: string; + }>; +} + // @public export type UrlReader = { read(url: string): Promise; diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index dfc845f3b2..fe3c7a5bec 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -20,6 +20,20 @@ export interface Config { }; backend: { + /** Backend configuration for when request authentication is enabled */ + auth?: { + /** Keys shared by all backends for signing and validating backend tokens. */ + keys: { + /** + * Secret for generating tokens. Should be a base64 string, recommended + * length is 24 bytes. + * + * @visibility secret + */ + secret: string; + }[]; + }; + baseUrl: string; // defined in core, but repeated here without doc /** Address that the backend should listen to. */ @@ -69,6 +83,25 @@ export interface Config { * Defaults to true if unspecified. */ ensureExists?: boolean; + /** + * How plugins databases are managed/divided in the provided database instance. + * + * `database` -> Plugins are each given their own database to manage their schemas/tables. + * + * `schema` -> Plugins will be given their own schema (in the specified/default database) + * to manage their tables. + * + * NOTE: Currently only supported by the `pg` client. + * + * @default database + */ + pluginDivisionMode?: 'database' | 'schema'; + /** + * Arbitrary config object to pass to knex when initializing + * (https://knexjs.org/#Installation-client). Most notable is the debug + * and asyncStackTraces booleans + */ + knexConfig?: object; /** Plugin specific database configuration and client override */ plugin?: { [pluginId: string]: { @@ -84,6 +117,14 @@ export interface Config { * Defaults to base config if unspecified. */ ensureExists?: boolean; + /** + * Arbitrary config object to pass to knex when initializing + * (https://knexjs.org/#Installation-client). Most notable is the + * debug and asyncStackTraces booleans. + * + * This is merged recursively into the base knexConfig + */ + knexConfig?: object; }; }; }; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 16ce62c7d7..e46d659175 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.9.9", + "version": "0.9.12", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -29,31 +29,31 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli-common": "^0.1.5", + "@backstage/cli-common": "^0.1.6", "@backstage/config": "^0.1.11", - "@backstage/config-loader": "^0.7.2", - "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", + "@backstage/config-loader": "^0.8.1", + "@backstage/errors": "^0.1.5", + "@backstage/integration": "^0.6.10", "@backstage/types": "^0.1.1", "@google-cloud/storage": "^5.8.0", "@lerna/project": "^4.0.0", "@octokit/rest": "^18.5.3", "@types/cors": "^2.8.6", - "@types/dockerode": "^3.2.1", + "@types/dockerode": "^3.3.0", "@types/express": "^4.17.6", "archiver": "^5.0.2", "aws-sdk": "^2.840.0", "compression": "^1.7.4", "concat-stream": "^2.0.0", "cors": "^2.8.5", - "cross-fetch": "^3.0.6", - "dockerode": "^3.2.1", + "dockerode": "^3.3.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", "fs-extra": "9.1.0", "git-url-parse": "^11.6.0", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", + "jose": "^1.27.1", "keyv": "^4.0.3", "keyv-memcache": "^1.2.5", "knex": "^0.95.1", @@ -63,6 +63,7 @@ "minimist": "^1.2.5", "morgan": "^1.10.0", "node-abort-controller": "^3.0.1", + "node-fetch": "^2.6.1", "raw-body": "^2.4.1", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", @@ -80,8 +81,8 @@ } }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/test-utils": "^0.1.23", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", diff --git a/packages/backend-common/src/database/DatabaseManager.test.ts b/packages/backend-common/src/database/DatabaseManager.test.ts index e839123908..1d2ff58b41 100644 --- a/packages/backend-common/src/database/DatabaseManager.test.ts +++ b/packages/backend-common/src/database/DatabaseManager.test.ts @@ -15,13 +15,18 @@ */ import { ConfigReader } from '@backstage/config'; import { omit } from 'lodash'; -import { createDatabaseClient, ensureDatabaseExists } from './connection'; +import { + createDatabaseClient, + ensureDatabaseExists, + ensureSchemaExists, +} from './connection'; import { DatabaseManager } from './DatabaseManager'; jest.mock('./connection', () => ({ ...jest.requireActual('./connection'), createDatabaseClient: jest.fn(), ensureDatabaseExists: jest.fn(), + ensureSchemaExists: jest.fn(), })); describe('DatabaseManager', () => { @@ -314,5 +319,228 @@ describe('DatabaseManager', () => { expect.stringContaining('userdbname'), ); }); + + it('plugin sets schema override for pg client', async () => { + const overrideConfig = { + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }; + const testManager = DatabaseManager.fromConfig( + new ConfigReader(overrideConfig), + ); + const pluginId = 'schemaoverride'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [baseConfig, overrides] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'pg', + connection: config.backend.database.connection, + }); + + expect(overrides).toMatchObject({ + searchPath: [pluginId], + }); + }); + + it('plugin does not provide schema override for non pg client', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }), + ); + const pluginId = 'any-plugin'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [baseConfig, overrides] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'sqlite3', + connection: config.backend.database.connection, + }); + + expect(overrides).not.toHaveProperty('searchPath'); + }); + + it('plugin does not provide schema override if pluginDivisionMode is set to database', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'database', + connection: 'some-file-path', + }, + }, + }), + ); + + const pluginId = 'any-plugin'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [_baseConfig, overrides] = mockCalls[0]; + + expect(overrides).not.toHaveProperty('searchPath'); + }); + + it('plugin does not provide schema override if pluginDivisionMode is not set', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }), + ); + + const pluginId = 'schemaoverride'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [_baseConfig, overrides] = mockCalls[0]; + + expect(overrides).not.toHaveProperty('searchPath'); + }); + + it('pluginDivisionMode ensures that each plugin schema exists', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }), + ); + const pluginId = 'testdbname'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(ensureSchemaExists).mock.calls; + const [_, schemaName] = mockCalls[0]; + + expect(schemaName).toEqual('testdbname'); + }); + + it('pluginDivisionMode allows connection overrides for plugins', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + pluginDivisionMode: 'schema', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + plugin: { + testdbname: { + connection: { + database: 'database_name_overriden', + host: 'newhost', + }, + }, + }, + }, + }, + }), + ); + const pluginId = 'testdbname'; + await testManager.forPlugin(pluginId).getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [baseConfig, overrides] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'pg', + connection: { + database: 'database_name_overriden', + host: 'newhost', + user: 'foo', + password: 'bar', + }, + }); + expect(overrides).toHaveProperty('searchPath', ['testdbname']); + expect(overrides).toHaveProperty( + 'connection.database', + 'database_name_overriden', + ); + }); + + it('fetches and merges additional knex config', async () => { + const testManager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + database: 'foodb', + }, + knexConfig: { + something: false, + }, + plugin: { + testdbname: { + knexConfig: { + debug: true, + }, + }, + }, + }, + }, + }), + ); + await testManager.forPlugin('testdbname').getClient(); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const [baseConfig] = mockCalls[0]; + + expect(baseConfig.data).toEqual( + expect.objectContaining({ + debug: true, + something: false, + }), + ); + }); }); }); diff --git a/packages/backend-common/src/database/DatabaseManager.ts b/packages/backend-common/src/database/DatabaseManager.ts index f8cdab1413..a3f31e98f2 100644 --- a/packages/backend-common/src/database/DatabaseManager.ts +++ b/packages/backend-common/src/database/DatabaseManager.ts @@ -14,14 +14,17 @@ * limitations under the License. */ -import { Knex } from 'knex'; -import { omit } from 'lodash'; import { Config, ConfigReader } from '@backstage/config'; import { JsonObject } from '@backstage/types'; +import { Knex } from 'knex'; +import { merge, omit } from 'lodash'; +import { mergeDatabaseConfig } from './config'; import { createDatabaseClient, createNameOverride, + createSchemaOverride, ensureDatabaseExists, + ensureSchemaExists, normalizeConnection, } from './connection'; import { PluginDatabaseManager } from './types'; @@ -80,14 +83,15 @@ export class DatabaseManager { * Provides the canonical database name for a given plugin. * * This method provides the effective database name which is determined using global - * and plugin specific database config. If no explicit database name is configured, - * this method will provide a generated name which is the pluginId prefixed with - * 'backstage_plugin_'. + * and plugin specific database config. If no explicit database name is configured + * and `pluginDivisionMode` is not `schema`, this method will provide a generated name + * which is the pluginId prefixed with 'backstage_plugin_'. If `pluginDivisionMode` is + * `schema`, it will fallback to using the default database for the knex instance. * * @param pluginId Lookup the database name for given plugin * @returns String representing the plugin's database name */ - private getDatabaseName(pluginId: string): string { + private getDatabaseName(pluginId: string): string | undefined { const connection = this.getConnectionConfig(pluginId); if (this.getClientType(pluginId).client === 'sqlite3') { @@ -96,11 +100,16 @@ export class DatabaseManager { (connection as Knex.Sqlite3ConnectionConfig)?.filename ?? ':memory:' ); } + + const databaseName = (connection as Knex.ConnectionConfig)?.database; + + // `pluginDivisionMode` as `schema` should use overridden databaseName if supplied or fallback to default knex database + if (this.getPluginDivisionModeConfig() === 'schema') { + return databaseName; + } + // all other supported databases should fallback to an auto-prefixed name - return ( - (connection as Knex.ConnectionConfig)?.database ?? - `${this.prefix}${pluginId}` - ); + return databaseName ?? `${this.prefix}${pluginId}`; } /** @@ -129,6 +138,24 @@ export class DatabaseManager { }; } + /** + * Provides the knexConfig which should be used for a given plugin. + * + * @param pluginId Plugin to get the knexConfig for + * @returns the merged kexConfig value or undefined if it isn't specified + */ + private getAdditionalKnexConfig(pluginId: string): JsonObject | undefined { + const pluginConfig = this.config + .getOptionalConfig(`${pluginPath(pluginId)}.knexConfig`) + ?.get(); + + const baseConfig = this.config + .getOptionalConfig('knexConfig') + ?.get(); + + return merge(baseConfig, pluginConfig); + } + private getEnsureExistsConfig(pluginId: string): boolean { const baseConfig = this.config.getOptionalBoolean('ensureExists') ?? true; return ( @@ -137,13 +164,18 @@ export class DatabaseManager { ); } + private getPluginDivisionModeConfig(): string { + return this.config.getOptionalString('pluginDivisionMode') ?? 'database'; + } + /** * Provides a Knex connection plugin config by combining base and plugin config. * * This method provides a baseConfig for a plugin database connector. If the client type * has not been overridden, the global connection config will be included with plugin * specific config as the base. Values from the plugin connection take precedence over the - * base. Base database name is omitted for all supported databases excluding SQLite. + * base. Base database name is omitted for all supported databases excluding SQLite unless + * `pluginDivisionMode` is set to `schema`. */ private getConnectionConfig( pluginId: string, @@ -154,10 +186,13 @@ export class DatabaseManager { this.config.get('connection'), this.config.getString('client'), ); - // As databases cannot be shared, the `database` property from the base connection - // is omitted. SQLite3's `filename` property is an exception as this is used as a + // Databases cannot be shared unless the `pluginDivisionMode` is set to `schema`. The + // `database` property from the base connection is omitted unless `pluginDivisionMode` + // is set to `schema`. SQLite3's `filename` property is an exception as this is used as a // directory elsewhere so we preserve `filename`. - baseConnection = omit(baseConnection, 'database'); + if (this.getPluginDivisionModeConfig() !== 'schema') { + baseConnection = omit(baseConnection, 'database'); + } // get and normalize optional plugin specific database connection const connection = normalizeConnection( @@ -183,11 +218,22 @@ export class DatabaseManager { const { client } = this.getClientType(pluginId); return { + ...this.getAdditionalKnexConfig(pluginId), client, connection: this.getConnectionConfig(pluginId), }; } + /** + * Provides a partial Knex.Config database schema override for a given plugin. + * + * @param pluginId Target plugin to get database schema override + * @returns Partial Knex.Config with database schema override + */ + private getSchemaOverrides(pluginId: string): Knex.Config | undefined { + return createSchemaOverride(this.getClientType(pluginId).client, pluginId); + } + /** * Provides a partial Knex.Config database name override for a given plugin. * @@ -195,10 +241,10 @@ export class DatabaseManager { * @returns Partial Knex.Config with database name override */ private getDatabaseOverrides(pluginId: string): Knex.Config { - return createNameOverride( - this.getClientType(pluginId).client, - this.getDatabaseName(pluginId), - ); + const databaseName = this.getDatabaseName(pluginId); + return databaseName + ? createNameOverride(this.getClientType(pluginId).client, databaseName) + : {}; } /** @@ -212,8 +258,8 @@ export class DatabaseManager { this.getConfigForPlugin(pluginId) as JsonObject, ); - if (this.getEnsureExistsConfig(pluginId)) { - const databaseName = this.getDatabaseName(pluginId); + const databaseName = this.getDatabaseName(pluginId); + if (databaseName && this.getEnsureExistsConfig(pluginId)) { try { await ensureDatabaseExists(pluginConfig, databaseName); } catch (error) { @@ -223,9 +269,24 @@ export class DatabaseManager { } } - return createDatabaseClient( - pluginConfig, + let schemaOverrides; + if (this.getPluginDivisionModeConfig() === 'schema') { + try { + schemaOverrides = this.getSchemaOverrides(pluginId); + await ensureSchemaExists(pluginConfig, pluginId); + } catch (error) { + throw new Error( + `Failed to connect to the database to make sure that schema for plugin '${pluginId}' exists, ${error}`, + ); + } + } + + const databaseClientOverrides = mergeDatabaseConfig( + {}, this.getDatabaseOverrides(pluginId), + schemaOverrides, ); + + return createDatabaseClient(pluginConfig, databaseClientOverrides); } } diff --git a/packages/backend-common/src/database/connection.test.ts b/packages/backend-common/src/database/connection.test.ts index 869722ddf7..6f02e7b2ed 100644 --- a/packages/backend-common/src/database/connection.test.ts +++ b/packages/backend-common/src/database/connection.test.ts @@ -18,8 +18,24 @@ import { ConfigReader } from '@backstage/config'; import { createDatabaseClient, createNameOverride, + createSchemaOverride, + ensureSchemaExists, parseConnectionString, } from './connection'; +import { pgConnector } from './connectors'; + +const mocked = (f: Function) => f as jest.Mock; + +jest.mock('./connectors', () => { + const connectors = jest.requireActual('./connectors'); + return { + ...connectors, + pgConnector: { + ...connectors.pgConnector, + ensureSchemaExists: jest.fn(), + }, + }; +}); describe('database connection', () => { describe('createDatabaseClient', () => { @@ -152,4 +168,63 @@ describe('database connection', () => { expect(() => parseConnectionString('sqlite://')).toThrow(); }); }); + + describe('createSchemaOverride', () => { + it('returns Knex config for postgres', () => { + expect(createSchemaOverride('pg', 'testpg')).toHaveProperty( + 'searchPath', + ['testpg'], + ); + }); + + it('throws error for sqlite', () => { + expect(createSchemaOverride('sqlite3', 'testsqlite')).toBeUndefined(); + }); + + it('returns Knex config for mysql', () => { + expect(createSchemaOverride('mysql', 'testmysql')).toBeUndefined(); + }); + + it('throws an error for unknown connection', () => { + expect(createSchemaOverride('unknown', 'testname')).toBeUndefined(); + }); + }); + + describe('ensureSchemaExists', () => { + it('returns sucessfully with pg client', async () => { + await ensureSchemaExists( + new ConfigReader({ + client: 'pg', + schema: 'catalog', + connection: 'postgresql://testuser:testpass@acme:5432/userdbname', + }), + 'catalog', + ); + + const mockCalls = mocked( + pgConnector.ensureSchemaExists as Function, + ).mock.calls.splice(-1); + const [baseConfig, schemaName] = mockCalls[0]; + + expect(baseConfig.get()).toMatchObject({ + client: 'pg', + connection: 'postgresql://testuser:testpass@acme:5432/userdbname', + }); + + expect(schemaName).toEqual('catalog'); + }); + + it('throws error for non pg client', () => { + return expect( + ensureSchemaExists( + new ConfigReader({ + client: 'sqlite3', + schema: 'catalog', + connection: ':memory:', + }), + 'catalog', + ), + ).resolves.toBeUndefined(); + }); + }); }); diff --git a/packages/backend-common/src/database/connection.ts b/packages/backend-common/src/database/connection.ts index 1c5cce3718..3308a26573 100644 --- a/packages/backend-common/src/database/connection.ts +++ b/packages/backend-common/src/database/connection.ts @@ -82,6 +82,23 @@ export async function ensureDatabaseExists( ); } +/** + * Ensures that the given schemas all exist, creating them if they do not. + * + * @public + */ +export async function ensureSchemaExists( + dbConfig: Config, + ...schemas: Array +): Promise { + const client: DatabaseClient = dbConfig.getString('client'); + + return await ConnectorMapping[client]?.ensureSchemaExists?.( + dbConfig, + ...schemas, + ); +} + /** * Provides a Knex.Config object with the provided database name for a given client. */ @@ -99,6 +116,23 @@ export function createNameOverride( } } +/** + * Provides a Knex.Config object with the provided database schema for a given client. Currently only supported by `pg`. + */ +export function createSchemaOverride( + client: string, + name: string, +): Partial { + try { + return ConnectorMapping[client]?.createSchemaOverride?.(name); + } catch (e) { + throw new InputError( + `Unable to create database schema override for '${client}' connector`, + e, + ); + } +} + /** * Parses a connection string for a given client and provides a connection config. */ diff --git a/packages/backend-common/src/database/connectors/defaultSchemaOverride.test.ts b/packages/backend-common/src/database/connectors/defaultSchemaOverride.test.ts new file mode 100644 index 0000000000..2db5ecd454 --- /dev/null +++ b/packages/backend-common/src/database/connectors/defaultSchemaOverride.test.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import defaultSchemaOverride from './defaultSchemaOverride'; + +describe('defaultNameOverride()', () => { + it('returns a partial knex static connection config with searchPath set to [schemaName]', () => { + const schemaName = 'schemaName'; + expect(defaultSchemaOverride(schemaName)).toHaveProperty('searchPath', [ + schemaName, + ]); + }); +}); diff --git a/packages/backend-common/src/database/connectors/defaultSchemaOverride.ts b/packages/backend-common/src/database/connectors/defaultSchemaOverride.ts new file mode 100644 index 0000000000..62ad8ab8c8 --- /dev/null +++ b/packages/backend-common/src/database/connectors/defaultSchemaOverride.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Knex } from 'knex'; + +/** + * Provides a partial knex config with schema name override. + * + * @param name schema name to get config override for + */ +export default function defaultSchemaOverride( + name: string, +): Partial { + return { + searchPath: [name], + }; +} diff --git a/packages/backend-common/src/database/connectors/postgres.test.ts b/packages/backend-common/src/database/connectors/postgres.test.ts index ac988a2880..553d019573 100644 --- a/packages/backend-common/src/database/connectors/postgres.test.ts +++ b/packages/backend-common/src/database/connectors/postgres.test.ts @@ -76,6 +76,24 @@ describe('postgres', () => { }); }); + it('overrides the schema name', () => { + const mockConnection = { + ...createMockConnection(), + schema: 'schemaName', + }; + + expect( + buildPgDatabaseConfig(createConfig(mockConnection), { + searchPath: ['schemaName'], + }), + ).toEqual({ + client: 'pg', + connection: mockConnection, + searchPath: ['schemaName'], + useNullAsDefault: true, + }); + }); + it('adds additional config settings', () => { const mockConnection = createMockConnection(); diff --git a/packages/backend-common/src/database/connectors/postgres.ts b/packages/backend-common/src/database/connectors/postgres.ts index 62de2e001e..823d50f73f 100644 --- a/packages/backend-common/src/database/connectors/postgres.ts +++ b/packages/backend-common/src/database/connectors/postgres.ts @@ -21,6 +21,7 @@ import { ForwardedError } from '@backstage/errors'; import { mergeDatabaseConfig } from '../config'; import { DatabaseConnector } from '../types'; import defaultNameOverride from './defaultNameOverride'; +import defaultSchemaOverride from './defaultSchemaOverride'; /** * Creates a knex postgres database connection @@ -135,6 +136,29 @@ export async function ensurePgDatabaseExists( } } +/** + * Creates the missing Postgres schema if it does not exist + * + * @param dbConfig The database config + * @param schemas The name of the schemas to create + */ +export async function ensurePgSchemaExists( + dbConfig: Config, + ...schemas: Array +): Promise { + const admin = createPgDatabaseClient(dbConfig); + + try { + const ensureSchema = async (database: string) => { + await admin.raw(`CREATE SCHEMA IF NOT EXISTS ??`, [database]); + }; + + await Promise.all(schemas.map(ensureSchema)); + } finally { + await admin.destroy(); + } +} + /** * PostgreSQL database connector. * @@ -143,6 +167,8 @@ export async function ensurePgDatabaseExists( export const pgConnector: DatabaseConnector = Object.freeze({ createClient: createPgDatabaseClient, ensureDatabaseExists: ensurePgDatabaseExists, + ensureSchemaExists: ensurePgSchemaExists, createNameOverride: defaultNameOverride, + createSchemaOverride: defaultSchemaOverride, parseConnectionString: parsePgConnectionString, }); diff --git a/packages/backend-common/src/database/types.ts b/packages/backend-common/src/database/types.ts index edcba1e643..e96f86980b 100644 --- a/packages/backend-common/src/database/types.ts +++ b/packages/backend-common/src/database/types.ts @@ -45,6 +45,11 @@ export interface DatabaseConnector { * database name. */ createNameOverride(name: string): Partial; + /** + * createSchemaOverride provides a partial knex config sufficient to override a + * PostgreSQL schema name within utilizing the `searchPath` knex configuration. + */ + createSchemaOverride?(name: string): Partial; /** * parseConnectionString produces a knex connection config object representing * a database connection string. @@ -64,4 +69,16 @@ export interface DatabaseConnector { dbConfig: Config, ...databases: Array ): Promise; + + /** + * ensureSchemaExists performs a side-effect to ensure schema names passed in are + * present. + * + * Calling this function on schemas which already exist should do nothing. + * Missing schemas should be created if needed. + */ + ensureSchemaExists?( + dbConfig: Config, + ...schemas: Array + ): Promise; } diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts index c1e092706b..2f1e44b293 100644 --- a/packages/backend-common/src/discovery/SingleHostDiscovery.ts +++ b/packages/backend-common/src/discovery/SingleHostDiscovery.ts @@ -37,7 +37,7 @@ export class SingleHostDiscovery implements PluginEndpointDiscovery { * for the internal one. * * The basePath defaults to `/api`, meaning the default full internal - * path for the `catalog` plugin will be `http://localhost:7000/api/catalog`. + * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`. */ static fromConfig(config: Config, options?: { basePath?: string }) { const basePath = options?.basePath ?? '/api'; diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index c214961a2e..e430ddc1a4 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -31,4 +31,5 @@ export * from './paths'; export * from './reading'; export * from './scm'; export * from './service'; +export * from './tokens'; export * from './util'; diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index 2a4226f88f..12db7d42a1 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -54,10 +54,13 @@ export function setRootLoggerRedactionList(redactionList: string[]) { * and replaces them with the corresponding identifier. */ function redactLogLine(info: winston.Logform.TransformableInfo) { - // TODO(hhogg): The logger is created before the config is loaded, - // because the logger is needed in the config loader. There is a risk of - // a secret being logged out during the config loading stage. - if (redactionRegExp) { + // TODO(hhogg): The logger is created before the config is loaded, because the + // logger is needed in the config loader. There is a risk of a secret being + // logged out during the config loading stage. + // TODO(freben): Added a check that info.message actually was a string, + // because it turned out that this was not necessarily guaranteed. + // https://github.com/backstage/backstage/issues/8306 + if (redactionRegExp && typeof info.message === 'string') { info.message = info.message.replace(redactionRegExp, '[REDACTED]'); } diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 82d1192b17..37d97b8941 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -22,7 +22,7 @@ import { getAzureRequestOptions, ScmIntegrations, } from '@backstage/integration'; -import fetch from 'cross-fetch'; +import fetch, { Response } from 'node-fetch'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; @@ -72,7 +72,13 @@ export class AzureUrlReader implements UrlReader { try { response = await fetch(builtUrl, { ...getAzureRequestOptions(this.integration.config), - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can + // be removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -123,7 +129,13 @@ export class AzureUrlReader implements UrlReader { ...getAzureRequestOptions(this.integration.config, { Accept: 'application/zip', }), - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can be + // removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }); if (!archiveAzureResponse.ok) { const message = `Failed to read tree from ${url}, ${archiveAzureResponse.status} ${archiveAzureResponse.statusText}`; diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index c1a113a50d..b0e8056370 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -23,7 +23,7 @@ import { getBitbucketRequestOptions, ScmIntegrations, } from '@backstage/integration'; -import fetch from 'cross-fetch'; +import fetch, { Response } from 'node-fetch'; import parseGitUrl from 'git-url-parse'; import { trimEnd } from 'lodash'; import { Minimatch } from 'minimatch'; @@ -94,7 +94,13 @@ export class BitbucketUrlReader implements UrlReader { try { response = await fetch(bitbucketUrl.toString(), { ...requestOptions, - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can be + // removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index d82b1f7f52..7090d26d35 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -15,7 +15,7 @@ */ import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import fetch from 'cross-fetch'; +import fetch, { Response } from 'node-fetch'; import { ReaderFactory, ReadTreeResponse, @@ -86,7 +86,13 @@ export class FetchUrlReader implements UrlReader { headers: { ...(options?.etag && { 'If-None-Match': options.etag }), }, - signal: options?.signal, + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can + // be removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + signal: options?.signal as any, }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 755e0f17e6..d0fbf6d8ae 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -21,7 +21,7 @@ import { ScmIntegrations, } from '@backstage/integration'; import { RestEndpointMethodTypes } from '@octokit/rest'; -import fetch from 'cross-fetch'; +import fetch, { RequestInit, Response } from 'node-fetch'; import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; @@ -110,7 +110,13 @@ export class GithubUrlReader implements UrlReader { ...(options?.etag && { 'If-None-Match': options.etag }), Accept: 'application/vnd.github.v3.raw', }, - signal: options?.signal, + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can + // be removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + signal: options?.signal as any, }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -165,7 +171,13 @@ export class GithubUrlReader implements UrlReader { repoDetails.repo.archive_url, commitSha, filepath, - { headers, signal: options?.signal }, + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can be + // removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + { headers, signal: options?.signal as any }, options, ); } @@ -189,7 +201,7 @@ export class GithubUrlReader implements UrlReader { repoDetails.repo.archive_url, commitSha, filepath, - { headers, signal: options?.signal }, + { headers, signal: options?.signal as any }, ); return { files, etag: commitSha }; diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 128aed3c85..2444e317a7 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -20,7 +20,7 @@ import { GitLabIntegration, ScmIntegrations, } from '@backstage/integration'; -import fetch from 'cross-fetch'; +import fetch, { Response } from 'node-fetch'; import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; @@ -76,7 +76,13 @@ export class GitlabUrlReader implements UrlReader { ...getGitLabRequestOptions(this.integration.config).headers, ...(etag && { 'If-None-Match': etag }), }, - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can be + // removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); @@ -145,7 +151,13 @@ export class GitlabUrlReader implements UrlReader { ).toString(), { ...getGitLabRequestOptions(this.integration.config), - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can + // be removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }, ); if (!commitsGitlabResponse.ok) { @@ -169,7 +181,13 @@ export class GitlabUrlReader implements UrlReader { )}/repository/archive?sha=${branch}`, { ...getGitLabRequestOptions(this.integration.config), - ...(signal && { signal }), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can + // be removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + ...(signal && { signal: signal as any }), }, ); if (!archiveGitLabResponse.ok) { diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 885baba54b..b085cccfd6 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -40,7 +40,7 @@ import { } from './config'; import { createHttpServer, createHttpsServer } from './hostFactory'; -export const DEFAULT_PORT = 7000; +export const DEFAULT_PORT = 7007; // '' is express default, which listens to all interfaces const DEFAULT_HOST = ''; // taken from the helmet source code - don't seem to be exported diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index 57b814da63..d69b00f04c 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -63,8 +63,8 @@ type CustomOrigin = ( * @example * ```json * { - * baseUrl: "http://localhost:7000", - * listen: "0.0.0.0:7000" + * baseUrl: "http://localhost:7007", + * listen: "0.0.0.0:7007" * } * ``` */ diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts index 3765cbcfdd..2ad379f31e 100644 --- a/packages/backend-common/src/service/types.ts +++ b/packages/backend-common/src/service/types.ts @@ -34,7 +34,7 @@ export type ServiceBuilder = { * * If no port is specified, the service will first look for an environment * variable named PORT and use that if present, otherwise it picks a default - * port (7000). + * port (7007). * * @param port - The port to listen on */ diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts new file mode 100644 index 0000000000..fe724a0a4b --- /dev/null +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -0,0 +1,189 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { TokenManager } from './types'; +import { ServerTokenManager } from './ServerTokenManager'; + +const emptyConfig = new ConfigReader({}); +const configWithSecret = new ConfigReader({ + backend: { auth: { keys: [{ secret: 'a-secret-key' }] } }, +}); + +describe('ServerTokenManager', () => { + it('should throw if secret in config does not exist', () => { + expect(() => ServerTokenManager.fromConfig(emptyConfig)).toThrowError(); + }); + + describe('getToken', () => { + it('should return a token if secret in config exists', async () => { + const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + expect((await tokenManager.getToken()).token).toBeDefined(); + }); + + it('should return a token string if using a noop TokenManager', async () => { + const tokenManager = ServerTokenManager.noop(); + expect((await tokenManager.getToken()).token).toBeDefined(); + }); + }); + + describe('authenticate', () => { + it('should not throw if token is valid', async () => { + const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + const { token } = await tokenManager.getToken(); + await expect(tokenManager.authenticate(token)).resolves.not.toThrow(); + }); + + it('should throw if token is invalid', async () => { + const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + await expect( + tokenManager.authenticate('random-string'), + ).rejects.toThrowError(/invalid server token/i); + }); + + it('should validate server tokens created by a different instance using the same secret', async () => { + const tokenManager1 = ServerTokenManager.fromConfig(configWithSecret); + const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret); + + const { token } = await tokenManager1.getToken(); + + await expect(tokenManager2.authenticate(token)).resolves.not.toThrow(); + }); + + it('should validate server tokens created using any of the secrets', async () => { + const tokenManager1 = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, + }), + ); + const tokenManager2 = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'd4e5f6' }] } }, + }), + ); + const tokenManager3 = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { + auth: { keys: [{ secret: 'a1b2c3' }, { secret: 'd4e5f6' }] }, + }, + }), + ); + + const { token: token1 } = await tokenManager1.getToken(); + await expect(tokenManager3.authenticate(token1)).resolves.not.toThrow(); + + const { token: token2 } = await tokenManager2.getToken(); + await expect(tokenManager3.authenticate(token2)).resolves.not.toThrow(); + }); + + it('should throw for server tokens created using a different secret', async () => { + const tokenManager1 = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, + }), + ); + const tokenManager2 = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'd4e5f6' }] } }, + }), + ); + + const { token } = await tokenManager1.getToken(); + + await expect(tokenManager2.authenticate(token)).rejects.toThrowError( + /invalid server token/i, + ); + }); + + it('should throw for server tokens created using a noop TokenManager', async () => { + const noopTokenManager = ServerTokenManager.noop(); + const tokenManager = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, + }), + ); + + const { token } = await noopTokenManager.getToken(); + + await expect(tokenManager.authenticate(token)).rejects.toThrowError( + /invalid server token/i, + ); + }); + }); + + describe('ServerTokenManager.fromConfig', () => { + it('should throw if backend auth configuration is missing', () => { + expect(() => + ServerTokenManager.fromConfig(new ConfigReader({})), + ).toThrow(); + }); + + it('should throw if no keys are included in the configuration', () => { + expect(() => + ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [] } }, + }), + ), + ).toThrow(); + }); + + it('should throw if any key is missing a secret property', () => { + expect(() => + ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { + auth: { + keys: [{ secret: '123' }, {}, { secret: '789' }], + }, + }, + }), + ), + ).toThrow(); + }); + }); + + describe('ServerTokenManager.noop', () => { + let noopTokenManager: TokenManager; + + beforeEach(() => { + noopTokenManager = ServerTokenManager.noop(); + }); + + it('should accept tokens it generates', async () => { + const { token } = await noopTokenManager.getToken(); + + await expect(noopTokenManager.authenticate(token)).resolves.not.toThrow(); + }); + + it('should accept tokens generated by other noop token managers', async () => { + const noopTokenManager2 = ServerTokenManager.noop(); + await expect( + noopTokenManager.authenticate( + ( + await noopTokenManager2.getToken() + ).token, + ), + ).resolves.not.toThrow(); + }); + + it('should accept signed tokens', async () => { + const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + await expect( + noopTokenManager.authenticate((await tokenManager.getToken()).token), + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts new file mode 100644 index 0000000000..35a07509b6 --- /dev/null +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JWKS, JWK, JWT } from 'jose'; +import { Config } from '@backstage/config'; +import { AuthenticationError } from '@backstage/errors'; +import { TokenManager } from './types'; + +class NoopTokenManager implements TokenManager { + async getToken() { + return { token: '' }; + } + + async authenticate() {} +} + +/** + * Creates and validates tokens for use during backend-to-backend + * authentication. + * + * @public + */ +export class ServerTokenManager implements TokenManager { + private readonly verificationKeys: JWKS.KeyStore; + private readonly signingKey: JWK.Key; + + static noop(): TokenManager { + return new NoopTokenManager(); + } + + static fromConfig(config: Config) { + return new ServerTokenManager( + config + .getConfigArray('backend.auth.keys') + .map(key => key.getString('secret')), + ); + } + + private constructor(secrets?: string[]) { + if (!secrets?.length) { + throw new Error( + 'No secrets provided when constructing ServerTokenManager', + ); + } + + this.verificationKeys = new JWKS.KeyStore( + secrets.map(k => JWK.asKey({ kty: 'oct', k })), + ); + this.signingKey = this.verificationKeys.all()[0]; + } + + async getToken(): Promise<{ token: string }> { + const jwt = JWT.sign({ sub: 'backstage-server' }, this.signingKey, { + algorithm: 'HS256', + }); + + return { token: jwt }; + } + + async authenticate(token: string): Promise { + try { + JWT.verify(token, this.verificationKeys); + } catch (e) { + throw new AuthenticationError('Invalid server token'); + } + } +} diff --git a/packages/backend-common/src/tokens/index.ts b/packages/backend-common/src/tokens/index.ts new file mode 100644 index 0000000000..43ff12e597 --- /dev/null +++ b/packages/backend-common/src/tokens/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { ServerTokenManager } from './ServerTokenManager'; +export type { TokenManager } from './types'; diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts new file mode 100644 index 0000000000..1fea018db9 --- /dev/null +++ b/packages/backend-common/src/tokens/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Interface for creating and validating tokens. + * + * @public + */ +export interface TokenManager { + getToken: () => Promise<{ token: string }>; + authenticate: (token: string) => Promise; +} diff --git a/packages/backend-tasks/package.json b/packages/backend-tasks/package.json index 10db5cfaac..a22895140e 100644 --- a/packages/backend-tasks/package.json +++ b/packages/backend-tasks/package.json @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.8", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.4", "@backstage/types": "^0.1.1", @@ -43,8 +43,8 @@ "zod": "^3.9.5" }, "devDependencies": { - "@backstage/backend-test-utils": "^0.1.8", - "@backstage/cli": "^0.8.1", + "@backstage/backend-test-utils": "^0.1.10", + "@backstage/cli": "^0.10.0", "jest": "^26.0.1", "wait-for-expect": "^3.0.2" }, diff --git a/packages/backend-test-utils/CHANGELOG.md b/packages/backend-test-utils/CHANGELOG.md index 287e40a296..584187b9d3 100644 --- a/packages/backend-test-utils/CHANGELOG.md +++ b/packages/backend-test-utils/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/backend-test-utils +## 0.1.10 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-common@0.9.12 + - @backstage/cli@0.10.0 + +## 0.1.9 + +### Patch Changes + +- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers` +- Updated dependencies + - @backstage/cli@0.9.0 + - @backstage/backend-common@0.9.10 + ## 0.1.8 ### Patch Changes diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 2f29c2412b..2c1724e50f 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-test-utils", "description": "Test helpers library for Backstage backends", - "version": "0.1.8", + "version": "0.1.10", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,18 +30,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/cli": "^0.8.0", + "@backstage/backend-common": "^0.9.12", + "@backstage/cli": "^0.10.0", "@backstage/config": "^0.1.9", "knex": "^0.95.1", "mysql2": "^2.2.5", "pg": "^8.3.0", "sqlite3": "^5.0.1", - "testcontainers": "^7.10.0", + "testcontainers": "^7.23.0", "uuid": "^8.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "jest": "^26.0.1" }, "files": [ diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 7cf7d9ddaa..7aa2fb5f55 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,49 @@ # example-backend +## 0.2.55 + +### Patch Changes + +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/plugin-scaffolder-backend@0.15.15 + - @backstage/plugin-auth-backend@0.4.10 + - @backstage/plugin-kubernetes-backend@0.3.20 + - @backstage/plugin-badges-backend@0.1.13 + - @backstage/plugin-catalog-backend@0.19.0 + - @backstage/plugin-code-coverage-backend@0.1.16 + - @backstage/plugin-jenkins-backend@0.1.9 + - @backstage/plugin-tech-insights-backend@0.1.3 + - @backstage/plugin-techdocs-backend@0.11.0 + - @backstage/plugin-todo-backend@0.1.14 + - @backstage/backend-common@0.9.12 + - @backstage/plugin-azure-devops-backend@0.2.3 + - @backstage/plugin-tech-insights-backend-module-jsonfc@0.1.2 + - @backstage/plugin-tech-insights-node@0.1.1 + - example-app@0.2.55 + +## 0.2.54 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-kubernetes-backend@0.3.19 + - @backstage/plugin-tech-insights-backend@0.1.2 + - @backstage/plugin-tech-insights-backend-module-jsonfc@0.1.1 + - @backstage/plugin-auth-backend@0.4.9 + - @backstage/plugin-scaffolder-backend@0.15.14 + - @backstage/plugin-catalog-backend@0.18.0 + - @backstage/plugin-kafka-backend@0.2.12 + - @backstage/backend-common@0.9.11 + - @backstage/plugin-azure-devops-backend@0.2.2 + - @backstage/plugin-badges-backend@0.1.12 + - @backstage/plugin-code-coverage-backend@0.1.15 + - @backstage/plugin-jenkins-backend@0.1.8 + - @backstage/plugin-proxy-backend@0.2.14 + - @backstage/plugin-rollbar-backend@0.1.16 + - @backstage/plugin-search-backend@0.2.7 + - @backstage/plugin-techdocs-backend@0.10.9 + ## 0.2.52 ### Patch Changes diff --git a/packages/backend/README.md b/packages/backend/README.md index e6f0c899ca..c2145705e0 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -37,7 +37,7 @@ Substitute `x` for actual values, or leave them as dummy values just to try out the backend without using the auth or sentry features. You can also, instead of using dummy values for a huge number of environment variables, remove those config directly from app-config.yaml file located in the root folder. -The backend starts up on port 7000 per default. +The backend starts up on port 7007 per default. ### Debugging diff --git a/packages/backend/package.json b/packages/backend/package.json index 4c854241d4..cc8a8ac76a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.52", + "version": "0.2.55", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -24,39 +24,39 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.9.9", - "@backstage/catalog-client": "^0.5.1", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/integration": "^0.6.8", - "@backstage/plugin-app-backend": "^0.3.17", - "@backstage/plugin-auth-backend": "^0.4.7", - "@backstage/plugin-azure-devops-backend": "^0.2.0", - "@backstage/plugin-badges-backend": "^0.1.11", - "@backstage/plugin-catalog-backend": "^0.17.3", - "@backstage/plugin-code-coverage-backend": "^0.1.14", + "@backstage/integration": "^0.6.10", + "@backstage/plugin-app-backend": "^0.3.19", + "@backstage/plugin-auth-backend": "^0.4.10", + "@backstage/plugin-azure-devops-backend": "^0.2.3", + "@backstage/plugin-badges-backend": "^0.1.13", + "@backstage/plugin-catalog-backend": "^0.19.0", + "@backstage/plugin-code-coverage-backend": "^0.1.16", "@backstage/plugin-graphql-backend": "^0.1.9", - "@backstage/plugin-jenkins-backend": "^0.1.7", - "@backstage/plugin-kubernetes-backend": "^0.3.18", - "@backstage/plugin-kafka-backend": "^0.2.10", - "@backstage/plugin-proxy-backend": "^0.2.13", - "@backstage/plugin-rollbar-backend": "^0.1.15", - "@backstage/plugin-scaffolder-backend": "^0.15.12", + "@backstage/plugin-jenkins-backend": "^0.1.9", + "@backstage/plugin-kubernetes-backend": "^0.3.20", + "@backstage/plugin-kafka-backend": "^0.2.12", + "@backstage/plugin-proxy-backend": "^0.2.14", + "@backstage/plugin-rollbar-backend": "^0.1.16", + "@backstage/plugin-scaffolder-backend": "^0.15.15", "@backstage/plugin-scaffolder-backend-module-rails": "^0.1.7", - "@backstage/plugin-search-backend": "^0.2.6", + "@backstage/plugin-search-backend": "^0.2.7", "@backstage/plugin-search-backend-node": "^0.4.2", "@backstage/plugin-search-backend-module-elasticsearch": "^0.0.5", "@backstage/plugin-search-backend-module-pg": "^0.2.1", - "@backstage/plugin-techdocs-backend": "^0.10.5", - "@backstage/plugin-tech-insights-backend": "^0.1.0", - "@backstage/plugin-tech-insights-node": "^0.1.0", - "@backstage/plugin-tech-insights-backend-module-jsonfc": "^0.1.0", - "@backstage/plugin-todo-backend": "^0.1.13", - "@gitbeaker/node": "^30.2.0", + "@backstage/plugin-techdocs-backend": "^0.11.0", + "@backstage/plugin-tech-insights-backend": "^0.1.3", + "@backstage/plugin-tech-insights-node": "^0.1.1", + "@backstage/plugin-tech-insights-backend-module-jsonfc": "^0.1.2", + "@backstage/plugin-todo-backend": "^0.1.14", + "@gitbeaker/node": "^34.6.0", "@octokit/rest": "^18.5.3", "azure-devops-node-api": "^11.0.1", - "dockerode": "^3.2.1", - "example-app": "^0.2.51", + "dockerode": "^3.3.1", + "example-app": "^0.2.55", "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-prom-bundle": "^6.3.6", @@ -68,8 +68,8 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@types/dockerode": "^3.2.1", + "@backstage/cli": "^0.10.0", + "@types/dockerode": "^3.3.0", "@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 f978e84da9..4426ed7767 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -33,6 +33,7 @@ import { SingleHostDiscovery, UrlReaders, useHotMemoize, + ServerTokenManager, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; @@ -60,6 +61,7 @@ function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); + const tokenManager = ServerTokenManager.noop(); root.info(`Created UrlReader ${reader}`); @@ -70,7 +72,7 @@ function makeCreateEnv(config: Config) { const logger = root.child({ type: 'plugin', plugin }); const database = databaseManager.forPlugin(plugin); const cache = cacheManager.forPlugin(plugin); - return { logger, cache, database, config, reader, discovery }; + return { logger, cache, database, config, reader, discovery, tokenManager }; }; } diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 4cf9c10021..9a8db0f0f9 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -59,6 +59,7 @@ export default async function createPlugin({ discovery, config, database, + tokenManager, }: PluginEnvironment) { // Initialize a connection to a search engine. const searchEngine = await createSearchEngine({ config, logger, database }); @@ -68,7 +69,10 @@ export default async function createPlugin({ // particular collator gathers entities from the software catalog. indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: DefaultCatalogCollator.fromConfig(config, { discovery }), + collator: DefaultCatalogCollator.fromConfig(config, { + discovery, + tokenManager, + }), }); indexBuilder.addCollator({ @@ -76,6 +80,7 @@ export default async function createPlugin({ collator: DefaultTechDocsCollator.fromConfig(config, { discovery, logger, + tokenManager, }), }); diff --git a/packages/backend/src/plugins/techInsights.ts b/packages/backend/src/plugins/techInsights.ts index abab1d133d..8ab6342a4f 100644 --- a/packages/backend/src/plugins/techInsights.ts +++ b/packages/backend/src/plugins/techInsights.ts @@ -17,10 +17,12 @@ import { createRouter, buildTechInsightsContext, createFactRetrieverRegistration, + entityOwnershipFactRetriever, + entityMetadataFactRetriever, + techdocsFactRetriever, } from '@backstage/plugin-tech-insights-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -import { CatalogClient } from '@backstage/catalog-client'; import { JsonRulesEngineFactCheckerFactory, JSON_RULE_ENGINE_CHECK_TYPE, @@ -38,41 +40,12 @@ export default async function createPlugin({ database, discovery, factRetrievers: [ - createFactRetrieverRegistration('5 4 * * 6', { - // Example cron, At 04:05 on Saturday. - id: 'testRetriever', - version: '1.1.2', - entityFilter: [{ kind: 'component' }], // EntityFilter to be used in the future (creating checks, graphs etc.) to figure out which entities this fact retrieves data for. - schema: { - examplenumberfact: { - type: 'integer', - description: 'Example fact returning a number', - }, - }, - handler: async _ctx => { - const catalogClient = new CatalogClient({ - discoveryApi: discovery, - }); - const entities = await catalogClient.getEntities({ - filter: [{ kind: 'component' }], - }); - - return Promise.resolve( - entities.items.map(it => { - return { - entity: { - namespace: it.metadata.namespace!!, - kind: it.kind, - name: it.metadata.name, - }, - facts: { - examplenumberfact: 2, - }, - }; - }), - ); - }, - }), + createFactRetrieverRegistration( + '* * * * *', // Example cron, every minute + entityOwnershipFactRetriever, + ), + createFactRetrieverRegistration('* * * * *', entityMetadataFactRetriever), + createFactRetrieverRegistration('* * * * *', techdocsFactRetriever), ], factCheckerFactory: new JsonRulesEngineFactCheckerFactory({ checks: [ @@ -81,14 +54,33 @@ export default async function createPlugin({ type: JSON_RULE_ENGINE_CHECK_TYPE, name: 'simpleTestCheck', description: 'Simple Check For Testing', - factIds: ['testRetriever'], + factIds: [ + 'entityMetadataFactRetriever', + 'techdocsFactRetriever', + 'entityOwnershipFactRetriever', + ], rule: { conditions: { all: [ { - fact: 'examplenumberfact', - operator: 'lessThan', - value: 5, + fact: 'hasGroupOwner', + operator: 'equal', + value: true, + }, + { + fact: 'hasTitle', + operator: 'equal', + value: true, + }, + { + fact: 'hasDescription', + operator: 'equal', + value: true, + }, + { + fact: 'hasAnnotationBackstageIoTechdocsRef', + operator: 'equal', + value: true, }, ], }, diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 8290e569ef..4be9c036b3 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -20,6 +20,7 @@ import { PluginCacheManager, PluginDatabaseManager, PluginEndpointDiscovery, + TokenManager, UrlReader, } from '@backstage/backend-common'; @@ -30,4 +31,5 @@ export type PluginEnvironment = { config: Config; reader: UrlReader; discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; }; diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index f5ec2116f7..1b11e3bcb2 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/catalog-client +## 0.5.2 + +### Patch Changes + +- 3bf2238187: Update to the right version of @backstage/errors +- Updated dependencies + - @backstage/catalog-model@0.9.7 + ## 0.5.1 ### Patch Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 9caac50c90..28a3d5f969 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/catalog-client", "description": "An isomorphic client for the catalog backend", - "version": "0.5.1", + "version": "0.5.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", + "@backstage/catalog-model": "^0.9.7", "@backstage/errors": "^0.1.4", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.8.2", + "@backstage/cli": "^0.10.0", "@types/jest": "^26.0.7", "msw": "^0.35.0" }, diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index e0c067c9c7..efd6513cdd 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/catalog-model +## 0.9.7 + +### Patch Changes + +- 8809b6c0dd: Update the json-schema dependency version. + ## 0.9.6 ### Patch Changes diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 17c89ed824..60331d9dc8 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/catalog-model", "description": "Types and validators that help describe the model of a Backstage Catalog", - "version": "0.9.6", + "version": "0.9.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -42,7 +42,7 @@ "yup": "^0.32.9" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.10.0", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", "yaml": "^1.9.2" diff --git a/packages/cli-common/CHANGELOG.md b/packages/cli-common/CHANGELOG.md index f3978798c6..e588c02edf 100644 --- a/packages/cli-common/CHANGELOG.md +++ b/packages/cli-common/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/cli-common +## 0.1.6 + +### Patch Changes + +- 677bfc2dd0: Keep backstage.json in sync + + The `versions:bump` script now takes care about updating the `version` property inside `backstage.json` file. The file is created if is not present. + ## 0.1.5 ### Patch Changes diff --git a/packages/cli-common/api-report.md b/packages/cli-common/api-report.md index 4b61cfedb8..34f56c3f19 100644 --- a/packages/cli-common/api-report.md +++ b/packages/cli-common/api-report.md @@ -3,6 +3,9 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +// @public +export const BACKSTAGE_JSON = 'backstage.json'; + // @public export function findPaths(searchDir: string): Paths; diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json index 5916cdb017..cdb4b01b6d 100644 --- a/packages/cli-common/package.json +++ b/packages/cli-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli-common", "description": "Common functionality used by cli, backend, and create-app", - "version": "0.1.5", + "version": "0.1.6", "private": false, "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/cli-common/src/index.ts b/packages/cli-common/src/index.ts index e7cda19514..8809764b43 100644 --- a/packages/cli-common/src/index.ts +++ b/packages/cli-common/src/index.ts @@ -20,6 +20,6 @@ * @packageDocumentation */ -export { findPaths } from './paths'; +export { findPaths, BACKSTAGE_JSON } from './paths'; export { isChildPath } from './isChildPath'; export type { Paths, ResolveFunc } from './paths'; diff --git a/packages/cli-common/src/paths.ts b/packages/cli-common/src/paths.ts index c9f486752f..2b1ca34ca5 100644 --- a/packages/cli-common/src/paths.ts +++ b/packages/cli-common/src/paths.ts @@ -167,3 +167,10 @@ export function findPaths(searchDir: string): Paths { resolveTargetRoot: (...paths) => resolvePath(getTargetRoot(), ...paths), }; } + +/** + * The name of the backstage's config file + * + * @public + */ +export const BACKSTAGE_JSON = 'backstage.json'; diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 0fc702851d..6be3a461bf 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,65 @@ # @backstage/cli +## 0.10.0 + +### Minor Changes + +- ea99ef5198: Remove the `backend:build-image` command from the CLI and added more deprecation warnings to other deprecated fields like `--lax` and `remove-plugin` + +### Patch Changes + +- e7230ef814: Bump react-dev-utils to v12 +- 416b68675d: build(dependencies): bump `style-loader` from 1.2.1 to 3.3.1 +- Updated dependencies + - @backstage/config-loader@0.8.1 + +## 0.9.1 + +### Patch Changes + +- dde216acf4: Switch the default test coverage provider from the jest default one to `'v8'`, which provides much better coverage information when using the default Backstage test setup. This is considered a bug fix as the current coverage information is often very inaccurate. +- 719cc87d2f: Disable ES transforms in tests transformed by the `jestSucraseTransform.js`. This is not considered a breaking change since all code is already transpiled this way in the development setup. +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- ee055cf6db: Update the default routes to use id instead of title +- Updated dependencies + - @backstage/errors@0.1.5 + +## 0.9.0 + +### Minor Changes + +- 25f637f39f: Tweaked style insertion logic to make sure that JSS stylesheets always receive the highest priority. + +### Patch Changes + +- 677bfc2dd0: Keep backstage.json in sync + + The `versions:bump` script now takes care about updating the `version` property inside `backstage.json` file. The file is created if is not present. + +- 8809b6c0dd: Update the json-schema dependency version. +- fdfd2f8a62: remove double config dep +- 1e99c73c75: Update internal usage of `configLoader.loadConfig` that now returns an object instead of an array of configs. +- 6dcfe227a2: Added a scaffolder backend module template for the `create` command. +- 4ca3542fdd: Fixed a bug where calling `backstage-cli backend:bundle --build-dependencies` with no dependencies to be built would cause all monorepo packages to be built instead. +- 867ea81d15: bump `@rollup/plugin-commonjs` from 17.1.0 to 21.0.1 +- 16d06f6ac3: Introduces new `backstage-cli create` command to replace `create-plugin` and make space for creating a wider array of things. The create command also adds a new template for creating isomorphic common plugin packages. +- Updated dependencies + - @backstage/config-loader@0.8.0 + - @backstage/cli-common@0.1.6 + ## 0.8.2 ### Patch Changes diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index e889d23c3a..1d33372c60 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -95,6 +95,7 @@ async function getProjectConfig(targetPath, displayName) { ...(displayName && { displayName }), rootDir: path.resolve(targetPath, 'src'), coverageDirectory: path.resolve(targetPath, 'coverage'), + coverageProvider: 'v8', collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'], moduleNameMapper: { '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), diff --git a/packages/cli/config/jestSucraseTransform.js b/packages/cli/config/jestSucraseTransform.js index 01acfee38d..c88909d21f 100644 --- a/packages/cli/config/jestSucraseTransform.js +++ b/packages/cli/config/jestSucraseTransform.js @@ -46,7 +46,11 @@ function process(source, filePath) { } if (transforms) { - return transform(source, { transforms, filePath }).code; + return transform(source, { + transforms, + filePath, + disableESTransforms: true, + }).code; } return source; diff --git a/packages/cli/package.json b/packages/cli/package.json index fcbc00f47d..fe8a529de1 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.8.2", + "version": "0.10.0", "private": false, "publishConfig": { "access": "public" @@ -28,19 +28,19 @@ "backstage-cli": "bin/backstage-cli" }, "dependencies": { - "@backstage/cli-common": "^0.1.5", + "@backstage/cli-common": "^0.1.6", "@backstage/config": "^0.1.11", - "@backstage/config-loader": "^0.7.2", - "@backstage/errors": "^0.1.4", + "@backstage/config-loader": "^0.8.1", + "@backstage/errors": "^0.1.5", "@backstage/types": "^0.1.1", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^4.0.0", "@lerna/project": "^4.0.0", "@octokit/request": "^5.4.12", "@rollup/plugin-commonjs": "^21.0.1", - "@rollup/plugin-json": "^4.0.2", + "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^13.0.0", - "@rollup/plugin-yaml": "^3.0.0", + "@rollup/plugin-yaml": "^3.1.0", "@spotify/eslint-config-base": "^12.0.0", "@spotify/eslint-config-react": "^12.0.0", "@spotify/eslint-config-typescript": "^12.0.0", @@ -62,7 +62,7 @@ "css-loader": "^5.2.6", "dashify": "^2.0.0", "diff": "^5.0.0", - "esbuild": "^0.8.56", + "esbuild": "^0.14.1", "eslint": "^7.30.0", "eslint-config-prettier": "^8.3.0", "eslint-formatter-friendly": "^7.0.0", @@ -90,19 +90,19 @@ "postcss": "^8.1.0", "process": "^0.11.10", "react": "^16.0.0", - "react-dev-utils": "^11.0.4", + "react-dev-utils": "^12.0.0-next.47", "react-hot-loader": "^4.12.21", "recursive-readdir": "^2.2.2", "replace-in-file": "^6.0.0", - "rollup": "2.44.x", - "rollup-plugin-dts": "^3.0.1", - "rollup-plugin-esbuild": "2.6.x", + "rollup": "^2.60.2", + "rollup-plugin-dts": "^4.0.1", + "rollup-plugin-esbuild": "^4.7.2", "rollup-plugin-peer-deps-external": "^2.2.2", "rollup-plugin-postcss": "^4.0.0", "rollup-pluginutils": "^2.8.2", "run-script-webpack-plugin": "^0.0.11", "semver": "^7.3.2", - "style-loader": "^1.2.1", + "style-loader": "^3.3.1", "sucrase": "^3.20.2", "tar": "^6.1.2", "terser-webpack-plugin": "^5.1.3", @@ -117,14 +117,14 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.9.9", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", + "@backstage/theme": "^0.2.14", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", "@types/fs-extra": "^9.0.1", diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts index 22e7fb9de9..4fa3d1ac6e 100644 --- a/packages/cli/src/commands/app/build.ts +++ b/packages/cli/src/commands/app/build.ts @@ -20,8 +20,17 @@ import { buildBundle } from '../../lib/bundler'; import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; import { loadCliConfig } from '../../lib/config'; import { paths } from '../../lib/paths'; +import chalk from 'chalk'; export default async (cmd: Command) => { + if (cmd.lax) { + console.warn( + chalk.yellow( + `[DEPRECATED] - The --lax option is deprecated and will be removed in the future. Please open an issue towards https://github.com/backstage/backstage that describes your use-case if you need the flag to stay around.`, + ), + ); + } + const { name } = await fs.readJson(paths.resolveTarget('package.json')); await buildBundle({ entry: 'src/index', diff --git a/packages/cli/src/commands/backend/build.ts b/packages/cli/src/commands/backend/build.ts index 6cb1347b1e..8c25f56746 100644 --- a/packages/cli/src/commands/backend/build.ts +++ b/packages/cli/src/commands/backend/build.ts @@ -14,10 +14,12 @@ * limitations under the License. */ +import { Command } from 'commander'; import { buildPackage, Output } from '../../lib/builder'; -export default async () => { +export default async (cmd: Command) => { await buildPackage({ outputs: new Set([Output.cjs, Output.types]), + minify: cmd.minify, }); }; diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts deleted file mode 100644 index b352e38203..0000000000 --- a/packages/cli/src/commands/backend/buildImage.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Command } from 'commander'; -import { yellow } from 'chalk'; -import fs from 'fs-extra'; -import { join as joinPath, relative as relativePath } from 'path'; -import { createDistWorkspace } from '../../lib/packager'; -import { paths } from '../../lib/paths'; -import { run } from '../../lib/run'; -import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; - -const PKG_PATH = 'package.json'; - -export default async (cmd: Command) => { - // Skip the preparation steps if we're being asked for help - if (cmd.args.includes('--help')) { - await run('docker', ['image', 'build', '--help']); - return; - } - - console.warn( - yellow(` -The backend:build-image command is deprecated and will be removed in the future. -Please use the backend:bundle command instead along with your own Docker setup. - - https://backstage.io/docs/deployment/docker -`), - ); - - const pkgPath = paths.resolveTarget(PKG_PATH); - const pkg = await fs.readJson(pkgPath); - const appConfigs = await findAppConfigs(); - const npmrc = (await fs.pathExists(paths.resolveTargetRoot('.npmrc'))) - ? ['.npmrc'] - : []; - const tempDistWorkspace = await createDistWorkspace([pkg.name], { - buildDependencies: Boolean(cmd.build), - files: [ - 'package.json', - 'yarn.lock', - ...npmrc, - ...appConfigs, - { src: paths.resolveTarget('Dockerfile'), dest: 'Dockerfile' }, - ], - parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), - skeleton: 'skeleton.tar', - }); - console.log(`Dist workspace ready at ${tempDistWorkspace}`); - - // all args are forwarded to docker build - await run('docker', ['image', 'build', '.', ...cmd.args], { - cwd: tempDistWorkspace, - }); - - await fs.remove(tempDistWorkspace); -}; - -/** - * Find all config files to copy into the image - */ -async function findAppConfigs(): Promise { - const files = []; - - for (const name of await fs.readdir(paths.targetRoot)) { - if (name.startsWith('app-config.') && name.endsWith('.yaml')) { - files.push(name); - } - } - - if (paths.targetRoot !== paths.targetDir) { - const dirPath = relativePath(paths.targetRoot, paths.targetDir); - - for (const name of await fs.readdir(paths.targetDir)) { - if (name.startsWith('app-config.') && name.endsWith('.yaml')) { - files.push(joinPath(dirPath, name)); - } - } - } - - return files; -} diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index a37d5e9ffe..94a8104ff1 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -30,7 +30,10 @@ export function registerCommands(program: CommanderStatic) { .command('app:build') .description('Build an app for a production release') .option('--stats', 'Write bundle stats to output directory') - .option('--lax', 'Do not require environment variables to be set') + .option( + '--lax', + '[DEPRECATED] - Do not require environment variables to be set', + ) .option(...configOption) .action(lazy(() => import('./app/build').then(m => m.default))); @@ -44,6 +47,7 @@ export function registerCommands(program: CommanderStatic) { program .command('backend:build') .description('Build a backend plugin') + .option('--minify', 'Minify the generated code') .action(lazy(() => import('./backend/build').then(m => m.default))); program @@ -55,16 +59,6 @@ export function registerCommands(program: CommanderStatic) { ) .action(lazy(() => import('./backend/bundle').then(m => m.default))); - program - .command('backend:build-image') - .allowUnknownOption(true) - .helpOption(', --backstage-cli-help') // Let docker handle --help - .option('--build', 'Build packages before packing them into the image') - .description( - 'Bundles the package into a docker image. This command is deprecated and will be removed.', - ) - .action(lazy(() => import('./backend/buildImage').then(m => m.default))); - program .command('backend:dev') .description('Start local development server with HMR for the backend') @@ -115,7 +109,7 @@ export function registerCommands(program: CommanderStatic) { program .command('remove-plugin') - .description('Removes plugin in the current repository') + .description('[DEPRECATED] - Removes plugin in the current repository') .action( lazy(() => import('./remove-plugin/removePlugin').then(m => m.default)), ); @@ -123,6 +117,7 @@ export function registerCommands(program: CommanderStatic) { program .command('plugin:build') .description('Build a plugin') + .option('--minify', 'Minify the generated code') .action(lazy(() => import('./plugin/build').then(m => m.default))); program diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts index 8a63a017a2..9c5e173989 100644 --- a/packages/cli/src/commands/plugin/build.ts +++ b/packages/cli/src/commands/plugin/build.ts @@ -14,10 +14,12 @@ * limitations under the License. */ +import { Command } from 'commander'; import { buildPackage, Output } from '../../lib/builder'; -export default async () => { +export default async (cmd: Command) => { await buildPackage({ outputs: new Set([Output.esm, Output.types]), + minify: cmd.minify, }); }; diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts index 11fdc8bb11..a82bfda36d 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -184,6 +184,12 @@ export const removeReferencesFromAppPackage = async ( }; export default async () => { + console.warn( + chalk.yellow( + '[DEPRECATED] - The remove-plugin command is deprecated and will be removed in the future.', + ), + ); + const questions: Question[] = [ { type: 'input', diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 96bd1ab6fd..bc4049f1f4 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -20,7 +20,7 @@ import { resolve as resolvePath } from 'path'; import { paths } from '../../lib/paths'; import { mapDependencies } from '../../lib/versioning'; import * as runObj from '../../lib/run'; -import bump from './bump'; +import bump, { bumpBackstageJsonVersion } from './bump'; import { withLogCollector } from '@backstage/test-utils'; // Remove log coloring to simplify log matching @@ -141,7 +141,7 @@ describe('bump', () => { 'Version bump complete!', ]); - expect(runObj.runPlain).toHaveBeenCalledTimes(3); + expect(runObj.runPlain).toHaveBeenCalledTimes(4); expect(runObj.runPlain).toHaveBeenCalledWith( 'yarn', 'info', @@ -245,3 +245,64 @@ describe('bump', () => { }); }); }); + +describe('bumpBackstageJsonVersion', () => { + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should bump version in backstage.json', async () => { + mockFs({ + '/backstage.json': JSON.stringify({ version: '0.0.1' }), + }); + paths.targetDir = '/'; + const latest = '1.4.1'; + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...path) => resolvePath('/', ...path)); + jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => + JSON.stringify({ + type: 'inspect', + data: { + name, + 'dist-tags': { + latest, + }, + }, + }), + ); + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + + await bumpBackstageJsonVersion(); + + const json = await fs.readJson('/backstage.json'); + expect(json).toEqual({ version: '1.4.1' }); + }); + + it("should create backstage.json if doesn't exist", async () => { + mockFs({}); + paths.targetDir = '/'; + const latest = '1.4.1'; + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...path) => resolvePath('/', ...path)); + jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => + JSON.stringify({ + type: 'inspect', + data: { + name, + 'dist-tags': { + latest, + }, + }, + }), + ); + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + + await bumpBackstageJsonVersion(); + + const json = await fs.readJson('/backstage.json'); + expect(json).toEqual({ version: '1.4.1' }); + }); +}); diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 7b967839b2..3898c1654a 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -27,6 +27,7 @@ import { Lockfile, } from '../../lib/versioning'; import { includedFilter, forbiddenDuplicatesFilter } from './lint'; +import { BACKSTAGE_JSON } from '@backstage/cli-common'; const DEP_TYPES = [ 'dependencies', @@ -182,6 +183,10 @@ export default async () => { await fs.writeJson(pkgPath, pkgJson, { spaces: 2 }); }); + console.log(); + + await bumpBackstageJsonVersion(); + console.log(); console.log( `Running ${chalk.blue('yarn install')} to install new versions`, @@ -271,6 +276,41 @@ function createVersionFinder() { }; } +export async function bumpBackstageJsonVersion() { + const backstageJsonPath = paths.resolveTargetRoot(BACKSTAGE_JSON); + const backstageJson = await fs.readJSON(backstageJsonPath).catch(e => { + if (e.code === 'ENOENT') { + // gracefully continue in case the file doesn't exist + return; + } + throw e; + }); + + const info = await fetchPackageInfo('@backstage/create-app'); + const { latest } = info['dist-tags']; + + if (backstageJson?.version === latest) { + return; + } + + console.log( + chalk.yellow( + typeof backstageJson === 'undefined' + ? `Creating ${BACKSTAGE_JSON}` + : `Bumping version in ${BACKSTAGE_JSON}`, + ), + ); + + await fs.writeJson( + backstageJsonPath, + { ...backstageJson, version: latest }, + { + spaces: 2, + encoding: 'utf8', + }, + ); +} + async function workerThreads( count: number, items: IterableIterator, diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index 3c95a87bdd..ba60e8fea9 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -100,6 +100,7 @@ export const makeConfigs = async ( }), esbuild({ target: 'es2019', + minify: options.minify, }), ], }); diff --git a/packages/cli/src/lib/builder/types.ts b/packages/cli/src/lib/builder/types.ts index f75d1b1dda..e88c388013 100644 --- a/packages/cli/src/lib/builder/types.ts +++ b/packages/cli/src/lib/builder/types.ts @@ -22,4 +22,5 @@ export enum Output { export type BuildOptions = { outputs: Set; + minify?: boolean; }; diff --git a/packages/cli/templates/default-backend-plugin/src/run.ts.hbs b/packages/cli/templates/default-backend-plugin/src/run.ts.hbs index 54d2716290..0a3ed2b7f0 100644 --- a/packages/cli/templates/default-backend-plugin/src/run.ts.hbs +++ b/packages/cli/templates/default-backend-plugin/src/run.ts.hbs @@ -18,7 +18,7 @@ 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 port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/packages/cli/templates/default-plugin/src/routes.ts.hbs b/packages/cli/templates/default-plugin/src/routes.ts.hbs index a5a278e8a7..7e5d8f85f0 100644 --- a/packages/cli/templates/default-plugin/src/routes.ts.hbs +++ b/packages/cli/templates/default-plugin/src/routes.ts.hbs @@ -1,5 +1,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: '{{ id }}', + id: '{{ id }}', }); diff --git a/packages/codemods/CHANGELOG.md b/packages/codemods/CHANGELOG.md index 1043d9718f..dc2cc744f3 100644 --- a/packages/codemods/CHANGELOG.md +++ b/packages/codemods/CHANGELOG.md @@ -1,5 +1,33 @@ # @backstage/codemods +## 0.1.24 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/core-plugin-api@0.2.2 + - @backstage/core-app-api@0.1.24 + +## 0.1.23 + +### Patch Changes + +- Updated dependencies + - @backstage/core-app-api@0.1.23 + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.1.22 + +### Patch Changes + +- Updated dependencies + - @backstage/cli-common@0.1.6 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 + ## 0.1.21 ### Patch Changes diff --git a/packages/codemods/package.json b/packages/codemods/package.json index 17e89c86f0..407c35c724 100644 --- a/packages/codemods/package.json +++ b/packages/codemods/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/codemods", "description": "A collection of codemods for Backstage projects", - "version": "0.1.21", + "version": "0.1.24", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "backstage-codemods": "bin/backstage-codemods" }, "dependencies": { - "@backstage/cli-common": "0.1.5", + "@backstage/cli-common": "0.1.6", "@backstage/core-app-api": "*", "@backstage/core-components": "*", "@backstage/core-plugin-api": "*", diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index 0f1bc27996..93fb5dd02c 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,33 @@ # @backstage/config-loader +## 0.8.1 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- 4bea7b81d3: Uses key visibility as fallback in non-object arrays + +## 0.8.0 + +### Minor Changes + +- 1e99c73c75: Update `loadConfig` to return `LoadConfigResult` instead of an array of `AppConfig`. + + This function is primarily used internally by other config loaders like `loadBackendConfig` which means no changes are required for most users. + + If you use `loadConfig` directly you will need to update your usage from: + + ```diff + - const appConfigs = await loadConfig(options) + + const { appConfigs } = await loadConfig(options) + ``` + +### Patch Changes + +- 8809b6c0dd: Update the json-schema dependency version. +- Updated dependencies + - @backstage/cli-common@0.1.6 + ## 0.7.2 ### Patch Changes diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 76dcec35c6..78f8da3443 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.7.2", + "version": "0.8.1", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli-common": "^0.1.5", + "@backstage/cli-common": "^0.1.6", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.4", "@backstage/types": "^0.1.1", @@ -41,10 +41,10 @@ "json-schema": "^0.4.0", "json-schema-merge-allof": "^0.8.1", "json-schema-traverse": "^1.0.0", + "node-fetch": "^2.6.1", "typescript-json-schema": "^0.51.0", "yaml": "^1.9.2", - "yup": "^0.32.9", - "node-fetch": "2.6.5" + "yup": "^0.32.9" }, "devDependencies": { "@types/jest": "^26.0.7", diff --git a/packages/config-loader/src/lib/schema/filtering.test.ts b/packages/config-loader/src/lib/schema/filtering.test.ts index 0395c7da6a..be07b915ed 100644 --- a/packages/config-loader/src/lib/schema/filtering.test.ts +++ b/packages/config-loader/src/lib/schema/filtering.test.ts @@ -20,6 +20,7 @@ import { filterByVisibility, filterErrorsByVisibility } from './filtering'; const data = { arr: ['f', 'b', 's'], + arrU: ['f', 'b', 't'], objArr: [ { f: 1, b: 2, s: 3 }, { f: 4, b: 5, s: 6 }, @@ -40,6 +41,8 @@ const data = { const visibility = new Map( Object.entries({ + '/arrU': 'frontend', + '/arrU/2': 'backend', '/arr/0': 'frontend', '/arr/1': 'backend', '/arr/2': 'secret', @@ -71,6 +74,9 @@ describe('filterByVisibility', () => { 'arr[0]', 'arr[1]', 'arr[2]', + 'arrU[0]', + 'arrU[1]', + 'arrU[2]', 'objArr[0].f', 'objArr[0].b', 'objArr[0].s', @@ -97,10 +103,12 @@ describe('filterByVisibility', () => { obj: { f: 'a' }, arrF: [], objF: {}, + arrU: ['f', 'b'], }, filteredKeys: [ 'arr[1]', 'arr[2]', + 'arrU[2]', 'objArr[0].b', 'objArr[0].s', 'objArr[1].b', @@ -120,6 +128,7 @@ describe('filterByVisibility', () => { { data: { arr: ['b'], + arrU: ['t'], objArr: [{ b: 2 }, { b: 5 }], obj: { b: {} }, arrF: [{ never: 'here' }], @@ -132,6 +141,8 @@ describe('filterByVisibility', () => { filteredKeys: [ 'arr[0]', 'arr[2]', + 'arrU[0]', + 'arrU[1]', 'objArr[0].f', 'objArr[0].s', 'objArr[1].f', @@ -154,6 +165,9 @@ describe('filterByVisibility', () => { filteredKeys: [ 'arr[0]', 'arr[1]', + 'arrU[0]', + 'arrU[1]', + 'arrU[2]', 'objArr[0].f', 'objArr[0].b', 'objArr[1].f', diff --git a/packages/config-loader/src/lib/schema/filtering.ts b/packages/config-loader/src/lib/schema/filtering.ts index b4226d7dad..b2f39d3cc4 100644 --- a/packages/config-loader/src/lib/schema/filtering.ts +++ b/packages/config-loader/src/lib/schema/filtering.ts @@ -61,11 +61,17 @@ export function filterByVisibility( const arr = new Array(); for (const [index, value] of jsonVal.entries()) { - const out = transform( - value, + let path = visibilityPath; + const hasVisibilityInIndex = visibilityByDataPath.get( `${visibilityPath}/${index}`, - `${filterPath}[${index}]`, ); + + if (hasVisibilityInIndex || typeof value === 'object') { + path = `${visibilityPath}/${index}`; + } + + const out = transform(value, path, `${filterPath}[${index}]`); + if (out !== undefined) { arr.push(out); } diff --git a/packages/core-app-api/CHANGELOG.md b/packages/core-app-api/CHANGELOG.md index b9e60b7dab..ff506f212f 100644 --- a/packages/core-app-api/CHANGELOG.md +++ b/packages/core-app-api/CHANGELOG.md @@ -1,5 +1,121 @@ # @backstage/core-app-api +## 0.1.24 + +### Patch Changes + +- 0e7f256034: Fixed a bug where `useRouteRef` would fail in situations where relative navigation was needed and the app was is mounted on a sub-path. This would typically show up as a failure to navigate to a tab on an entity page. +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.1.23 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- 000190de69: The `ApiRegistry` from `@backstage/core-app-api` class has been deprecated and will be removed in a future release. To replace it, we have introduced two new helpers that are exported from `@backstage/test-utils`, namely `TestApiProvider` and `TestApiRegistry`. + + These two new helpers are more tailored for writing tests and development setups, as they allow for partial implementations of each of the APIs. + + When migrating existing code it is typically best to prefer usage of `TestApiProvider` when possible, so for example the following code: + + ```tsx + render( + + {...} + + ) + ``` + + Would be migrated to this: + + ```tsx + render( + + {...} + + ) + ``` + + In cases where the `ApiProvider` is used in a more standalone way, for example to reuse a set of APIs across multiple tests, the `TestApiRegistry` can be used instead. Note that the `TestApiRegistry` only has a single static factory method, `.from()`, and it is slightly different from the existing `.from()` method on `ApiRegistry` in that it doesn't require the API pairs to be wrapped in an outer array. + + Usage that looks like this: + + ```ts + const apis = ApiRegistry.with( + identityApiRef, + mockIdentityApi as unknown as IdentityApi, + ).with(configApiRef, new ConfigReader({})); + ``` + + OR like this: + + ```ts + const apis = ApiRegistry.from([ + [identityApiRef, mockIdentityApi as unknown as IdentityApi], + [configApiRef, new ConfigReader({})], + ]); + ``` + + Would be migrated to this: + + ```ts + const apis = TestApiRegistry.from( + [identityApiRef, mockIdentityApi], + [configApiRef, new ConfigReader({})], + ); + ``` + + If your app is still using the `ApiRegistry` to construct the `apis` for `createApp`, we recommend that you move over to use the new method of supplying API factories instead, using `createApiFactory`. + +- Updated dependencies + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.1.22 + +### Patch Changes + +- Reverted the `createApp` TypeScript type to match the one before version `0.1.21`, as it was an accidental breaking change. + +## 0.1.21 + +### Patch Changes + +- 0b1de52732: Migrated to using new `ErrorApiError` and `ErrorApiErrorContext` names. +- ecd1fcb80a: Deprecated the `BackstagePluginWithAnyOutput` type. +- 32bfbafb0f: Start exporting and marking several types as public to address errors in the API report. +- 014cbf8cb9: The `createApp` function from `@backstage/core-app-api` has been deprecated, with two new options being provided as a replacement. + + The first and most commonly used one is `createApp` from the new `@backstage/app-defaults` package, which behaves just like the existing `createApp`. In the future this method is likely to be expanded to add more APIs and other pieces into the default setup, for example the Utility APIs from `@backstage/integration-react`. + + The other option that we now provide is to use `createSpecializedApp` from `@backstage/core-app-api`. This is a more low-level API where you need to provide a full set of options, including your own `components`, `icons`, `defaultApis`, and `themes`. The `createSpecializedApp` way of creating an app is particularly useful if you are not using `@backstage/core-components` or MUI, as it allows you to avoid those dependencies completely. + +- 475edb5bc5: move the BehaviorSubject init into the constructor +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/app-defaults@0.1.1 + ## 0.1.20 ### Patch Changes diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index a67d2a2225..e69ee4f325 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -27,6 +27,7 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ConfigReader } from '@backstage/config'; +import { createApp as createApp_2 } from '@backstage/app-defaults'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; import { ErrorApiError } from '@backstage/core-plugin-api'; @@ -45,7 +46,6 @@ import { Observable } from '@backstage/types'; import { oktaAuthApiRef } from '@backstage/core-plugin-api'; import { oneloginAuthApiRef } from '@backstage/core-plugin-api'; import { OpenIdConnectApi } from '@backstage/core-plugin-api'; -import { OptionalAppOptions } from '@backstage/app-defaults'; import { PendingAuthRequest } from '@backstage/core-plugin-api'; import { PluginOutput } from '@backstage/core-plugin-api'; import { ProfileInfo } from '@backstage/core-plugin-api'; @@ -126,7 +126,7 @@ export type ApiProviderProps = { children: ReactNode; }; -// @public +// @public @deprecated export class ApiRegistry implements ApiHolder { constructor(apis: Map); // Warning: (ae-forgotten-export) The symbol "ApiRegistryBuilder" needs to be exported by the entry point index.d.ts @@ -332,7 +332,9 @@ export type BootErrorPageProps = { export { ConfigReader }; // @public @deprecated -export function createApp(options?: OptionalAppOptions): BackstageApp; +export function createApp( + options?: Parameters[0], +): BackstageApp & AppContext; // @public export function createSpecializedApp(options: AppOptions): BackstageApp; diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index fc6c103109..9ab3e097d1 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-app-api", "description": "Core app API used by Backstage apps", - "version": "0.1.20", + "version": "0.1.24", "private": false, "publishConfig": { "access": "public", @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/app-defaults": "^0.1.0", - "@backstage/core-components": "^0.7.3", + "@backstage/app-defaults": "^0.1.1", + "@backstage/core-components": "^0.7.6", "@backstage/config": "^0.1.11", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", "@backstage/version-bridge": "^0.1.0", "@material-ui/core": "^4.12.2", @@ -47,8 +47,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts index 7fcbde97df..5e404a35d7 100644 --- a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.test.ts @@ -26,10 +26,10 @@ describe('UrlPatternDiscovery', () => { it('should use a plain pattern', async () => { const discoveryApi = UrlPatternDiscovery.compile( - 'http://localhost:7000/{{ pluginId }}', + 'http://localhost:7007/{{ pluginId }}', ); await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe( - 'http://localhost:7000/my-plugin', + 'http://localhost:7007/my-plugin', ); }); diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts index aa921c2595..cba8344977 100644 --- a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts @@ -30,7 +30,7 @@ export class UrlPatternDiscovery implements DiscoveryApi { * interpolation done for the template is to replace instances of `{{pluginId}}` * with the ID of the plugin being requested. * - * Example pattern: `http://localhost:7000/api/{{ pluginId }}` + * Example pattern: `http://localhost:7007/api/{{ pluginId }}` */ static compile(pattern: string): UrlPatternDiscovery { const parts = pattern.split(/\{\{\s*pluginId\s*\}\}/); diff --git a/packages/core-app-api/src/apis/system/ApiRegistry.ts b/packages/core-app-api/src/apis/system/ApiRegistry.ts index 29c8b1cfe4..e433381dd1 100644 --- a/packages/core-app-api/src/apis/system/ApiRegistry.ts +++ b/packages/core-app-api/src/apis/system/ApiRegistry.ts @@ -36,6 +36,7 @@ class ApiRegistryBuilder { * A registry for utility APIs. * * @public + * @deprecated Will be removed, use {@link @backstage/test-utils#TestApiProvider} or {@link @backstage/test-utils#TestApiRegistry} instead. */ export class ApiRegistry implements ApiHolder { static builder() { diff --git a/packages/core-app-api/src/app/AppManager.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx index 07401660e8..20872b26f0 100644 --- a/packages/core-app-api/src/app/AppManager.test.tsx +++ b/packages/core-app-api/src/app/AppManager.test.tsx @@ -349,6 +349,78 @@ describe('Integration Test', () => { }); }); + it('getFeatureFlags should return feature flags', async () => { + const storageFlags = new LocalStorageFeatureFlags(); + jest.spyOn(storageFlags, 'registerFlag'); + + const apis = [ + noOpAnalyticsApi, + createApiFactory({ + api: featureFlagsApiRef, + deps: { configApi: configApiRef }, + factory() { + return storageFlags; + }, + }), + ]; + + const app = new AppManager({ + apis, + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + Provider: ({ children }) => <>{children}, + }, + ], + icons, + plugins: [ + createPlugin({ + id: 'test', + featureFlags: [ + { + name: 'foo', + }, + ], + register: p => p.featureFlags.register('name'), + }), + ], + components, + configLoader: async () => [], + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + } /> + } /> + + + , + ); + + expect(storageFlags.registerFlag).toHaveBeenCalledWith({ + name: 'name', + pluginId: 'test', + }); + expect(storageFlags.registerFlag).toHaveBeenCalledWith({ + name: 'foo', + pluginId: 'test', + }); + }); + it('should track route changes via analytics api', async () => { const mockAnalyticsApi = new MockAnalyticsApi(); const apis = [createApiFactory(analyticsApiRef, mockAnalyticsApi)]; diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 950f57bb7d..5903a8c874 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -272,17 +272,26 @@ export class AppManager implements BackstageApp { const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!; for (const plugin of this.plugins.values()) { - for (const output of plugin.output()) { - switch (output.type) { - case 'feature-flag': { - featureFlagsApi.registerFlag({ - name: output.name, - pluginId: plugin.getId(), - }); - break; + if ('getFeatureFlags' in plugin) { + for (const flag of plugin.getFeatureFlags()) { + featureFlagsApi.registerFlag({ + name: flag.name, + pluginId: plugin.getId(), + }); + } + } else { + for (const output of plugin.output()) { + switch (output.type) { + case 'feature-flag': { + featureFlagsApi.registerFlag({ + name: output.name, + pluginId: plugin.getId(), + }); + break; + } + default: + break; } - default: - break; } } } diff --git a/packages/core-app-api/src/app/createApp.tsx b/packages/core-app-api/src/app/createApp.tsx index e11076099b..b2a21634d3 100644 --- a/packages/core-app-api/src/app/createApp.tsx +++ b/packages/core-app-api/src/app/createApp.tsx @@ -14,10 +14,8 @@ * limitations under the License. */ -import { - createApp as createDefaultApp, - OptionalAppOptions, -} from '@backstage/app-defaults'; +import { createApp as createDefaultApp } from '@backstage/app-defaults'; +import { AppContext, BackstageApp } from './types'; /** * Creates a new Backstage App. @@ -26,7 +24,9 @@ import { * @param options - A set of options for creating the app * @public */ -export function createApp(options?: OptionalAppOptions) { +export function createApp( + options?: Parameters[0], +): BackstageApp & AppContext { // eslint-disable-next-line no-console console.warn( 'DEPRECATION WARNING: The createApp function from @backstage/core-app-api will soon be removed, ' + @@ -34,5 +34,5 @@ export function createApp(options?: OptionalAppOptions) { 'If you do not wish to use a standard app configuration but instead supply all options yourself ' + ' you can use createSpecializedApp from @backstage/core-app-api instead.', ); - return createDefaultApp(options); + return createDefaultApp(options) as BackstageApp & AppContext; } diff --git a/packages/core-app-api/src/lib/subjects.ts b/packages/core-app-api/src/lib/subjects.ts index 79c2c25675..e0789da92c 100644 --- a/packages/core-app-api/src/lib/subjects.ts +++ b/packages/core-app-api/src/lib/subjects.ts @@ -121,34 +121,37 @@ export class PublishSubject * * See http://reactivex.io/documentation/subject.html */ + export class BehaviorSubject implements Observable, ZenObservable.SubscriptionObserver { - private isClosed = false; + private isClosed: boolean; private currentValue: T; - private terminatingError?: Error; + private terminatingError: Error | undefined; + private readonly observable: Observable; constructor(value: T) { + this.isClosed = false; this.currentValue = value; - } - - private readonly observable = new ObservableImpl(subscriber => { - if (this.isClosed) { - if (this.terminatingError) { - subscriber.error(this.terminatingError); - } else { - subscriber.complete(); + this.terminatingError = undefined; + this.observable = new ObservableImpl(subscriber => { + if (this.isClosed) { + if (this.terminatingError) { + subscriber.error(this.terminatingError); + } else { + subscriber.complete(); + } + return () => {}; } - return () => {}; - } - subscriber.next(this.currentValue); + subscriber.next(this.currentValue); - this.subscribers.add(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; - }); + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + } private readonly subscribers = new Set< ZenObservable.SubscriptionObserver diff --git a/packages/core-app-api/src/routing/FeatureFlagged.test.tsx b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx index 2b05c8f61d..d1b45c39d1 100644 --- a/packages/core-app-api/src/routing/FeatureFlagged.test.tsx +++ b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx @@ -16,14 +16,15 @@ import React from 'react'; import { FeatureFlagged } from './FeatureFlagged'; import { render } from '@testing-library/react'; -import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis'; +import { LocalStorageFeatureFlags } from '../apis'; +import { TestApiProvider } from '@backstage/test-utils'; import { featureFlagsApiRef } from '@backstage/core-plugin-api'; const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); describe('FeatureFlagged', () => { diff --git a/packages/core-app-api/src/routing/FlatRoutes.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.test.tsx index 0df296cc59..ca247d46cd 100644 --- a/packages/core-app-api/src/routing/FlatRoutes.test.tsx +++ b/packages/core-app-api/src/routing/FlatRoutes.test.tsx @@ -17,17 +17,18 @@ import { render, RenderResult } from '@testing-library/react'; import React, { ReactNode } from 'react'; import { MemoryRouter, Route, Routes, useOutlet } from 'react-router-dom'; -import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis'; +import { LocalStorageFeatureFlags } from '../apis'; import { featureFlagsApiRef } from '@backstage/core-plugin-api'; import { AppContext } from '../app'; import { AppContextProvider } from '../app/AppContext'; import { FlatRoutes } from './FlatRoutes'; +import { TestApiProvider } from '@backstage/test-utils'; const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); function makeRouteRenderer(node: ReactNode) { diff --git a/packages/core-app-api/src/routing/RouteResolver.test.ts b/packages/core-app-api/src/routing/RouteResolver.test.ts index 1b7eb10003..b068bd5e6d 100644 --- a/packages/core-app-api/src/routing/RouteResolver.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.test.ts @@ -100,23 +100,55 @@ describe('RouteResolver', () => { expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined); }); - it('should resolve an absolute route and an app base path', () => { + it('should resolve an absolute route and sub route with an app base path', () => { const r = new RouteResolver( - new Map([[ref1, '/my-route']]), - new Map(), - [{ routeRefs: new Set([ref1]), path: '/my-route', ...rest }], + new Map([ + [ref2, '/my-parent/:x'], + [ref1, '/my-route'], + ]), + new Map([[ref1, ref2]]), + [ + { + routeRefs: new Set([ref2]), + path: '/my-parent/:x', + ...rest, + children: [ + MATCH_ALL_ROUTE, + { routeRefs: new Set([ref1]), path: '/my-route', ...rest }, + ], + }, + ], new Map(), '/base', ); - expect(r.resolve(ref1, '/')?.()).toBe('/base/my-route'); - expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined); - expect(r.resolve(subRef1, '/')?.()).toBe('/base/my-route/foo'); - expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe( - '/base/my-route/foo/2a', + expect(r.resolve(ref1, '/my-parent/1x')?.()).toBe( + '/base/my-parent/1x/my-route', + ); + expect(r.resolve(ref1, '/base/my-parent/1x')?.()).toBe( + '/base/my-parent/1x/my-route', + ); + expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/base/my-parent/1x'); + expect(r.resolve(ref2, '/base')?.({ x: '1x' })).toBe('/base/my-parent/1x'); + expect(r.resolve(ref3, '/')?.({ y: '1y' })).toBe(undefined); + expect(r.resolve(subRef1, '/my-parent/2x')?.()).toBe( + '/base/my-parent/2x/my-route/foo', + ); + expect(r.resolve(subRef1, '/base/my-parent/2x')?.()).toBe( + '/base/my-parent/2x/my-route/foo', + ); + expect(r.resolve(subRef2, '/my-parent/3x')?.({ a: '2a' })).toBe( + '/base/my-parent/3x/my-route/foo/2a', + ); + expect(r.resolve(subRef2, '/base/my-parent/3x')?.({ a: '2a' })).toBe( + '/base/my-parent/3x/my-route/foo/2a', + ); + expect(r.resolve(subRef3, '/')?.({ x: '5x' })).toBe( + '/base/my-parent/5x/bar', + ); + expect(r.resolve(subRef4, '/')?.({ x: '6x', a: '4a' })).toBe( + '/base/my-parent/6x/bar/4a', ); - expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined); - expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined); expect(r.resolve(externalRef1, '/')?.()).toBe(undefined); expect(r.resolve(externalRef2, '/')?.()).toBe(undefined); expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined); diff --git a/packages/core-app-api/src/routing/RouteResolver.ts b/packages/core-app-api/src/routing/RouteResolver.ts index b5be5f893e..f186b28586 100644 --- a/packages/core-app-api/src/routing/RouteResolver.ts +++ b/packages/core-app-api/src/routing/RouteResolver.ts @@ -207,6 +207,20 @@ export class RouteResolver { return undefined; } + // The location that we get passed in uses the full path, so start by trimming off + // the app base path prefix in case we're running the app on a sub-path. + let relativeSourceLocation: Parameters[1]; + if (typeof sourceLocation === 'string') { + relativeSourceLocation = this.trimPath(sourceLocation); + } else if (sourceLocation.pathname) { + relativeSourceLocation = { + ...sourceLocation, + pathname: this.trimPath(sourceLocation.pathname), + }; + } else { + relativeSourceLocation = sourceLocation; + } + // Next we figure out the base path, which is the combination of the common parent path // between our current location and our target location, as well as the additional path // that is the difference between the parent path and the base of our target location. @@ -214,7 +228,7 @@ export class RouteResolver { this.appBasePath + resolveBasePath( targetRef, - sourceLocation, + relativeSourceLocation, this.routePaths, this.routeParents, this.routeObjects, @@ -225,4 +239,15 @@ export class RouteResolver { }; return routeFunc; } + + private trimPath(targetPath: string) { + if (!targetPath) { + return targetPath; + } + + if (targetPath.startsWith(this.appBasePath)) { + return targetPath.slice(this.appBasePath.length); + } + return targetPath; + } } diff --git a/packages/core-components/CHANGELOG.md b/packages/core-components/CHANGELOG.md index 1e62df337d..24d460279d 100644 --- a/packages/core-components/CHANGELOG.md +++ b/packages/core-components/CHANGELOG.md @@ -1,5 +1,36 @@ # @backstage/core-components +## 0.7.6 + +### Patch Changes + +- e34f174fc5: Added `` and `` to enable building better sidebars. You can check out the storybook for more inspiration and how to get started. + + Added two new theme props for styling the sidebar too, `navItem.hoverBackground` and `submenu.background`. + +- Updated dependencies + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.7.5 + +### Patch Changes + +- 157530187a: Pin sidebar by default for easier navigation +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/core-plugin-api@0.2.1 + +## 0.7.4 + +### Patch Changes + +- 274a4fc633: Add Props Icon for Sidebar Item SidebarSearchField and Settings +- 682945e233: Changing the `Header` styles to use more theme variables. With this the title `font-size` will not change on resizing the window. +- 892c1d9202: Update OAuthAdapter to create identity.token from identity.idToken if it does not exist, and prevent overwrites to identity.toke. Update login page commonProvider to prefer .token over .idToken +- Updated dependencies + - @backstage/core-plugin-api@0.2.0 + ## 0.7.3 ### Patch Changes diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 2fa3315fe1..63cd694d28 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -838,6 +838,7 @@ export const SidebarContext: Context; // @public (undocumented) export type SidebarContextType = { isOpen: boolean; + setOpen: (open: boolean) => void; }; // Warning: (ae-missing-release-tag) "SidebarDivider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1130,6 +1131,9 @@ export interface SidebarGroupProps extends BottomNavigationActionProps { to?: string; } +// @public +export const SidebarExpandButton: () => JSX.Element | null; + // Warning: (ae-missing-release-tag) "SidebarIntro" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2004,6 +2008,37 @@ export const SidebarSpacer: React_2.ComponentType< } >; +// @public +export const SidebarSubmenu: ({ + title, + children, +}: PropsWithChildren) => JSX.Element; + +// @public +export const SidebarSubmenuItem: ( + props: SidebarSubmenuItemProps, +) => JSX.Element; + +// @public +export type SidebarSubmenuItemDropdownItem = { + title: string; + to: string; +}; + +// @public +export type SidebarSubmenuItemProps = { + title: string; + to: string; + icon: IconComponent; + dropdownItems?: SidebarSubmenuItemDropdownItem[]; +}; + +// @public +export type SidebarSubmenuProps = { + title?: string; + children: ReactNode; +}; + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "SignInPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/packages/core-components/package.json b/packages/core-components/package.json index ffbde0072a..80bfe64629 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-components", "description": "Core components used by Backstage plugins and apps", - "version": "0.7.3", + "version": "0.7.6", "private": false, "publishConfig": { "access": "public", @@ -30,9 +30,9 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/errors": "^0.1.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/errors": "^0.1.5", + "@backstage/theme": "^0.2.14", "@material-table/core": "^3.1.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -46,6 +46,7 @@ "d3-shape": "^3.0.0", "d3-zoom": "^3.0.0", "dagre": "^0.8.5", + "history": "^5.0.0", "immer": "^9.0.1", "lodash": "^4.17.21", "pluralize": "^8.0.0", @@ -67,9 +68,9 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/core-app-api": "^0.1.20", - "@backstage/cli": "^0.8.2", - "@backstage/test-utils": "^0.1.21", + "@backstage/core-app-api": "^0.1.24", + "@backstage/cli": "^0.10.0", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx index d6bf990f8a..477057f31b 100644 --- a/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx +++ b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx @@ -17,78 +17,66 @@ import React from 'react'; import { AlertDisplay } from './AlertDisplay'; import { alertApiRef } from '@backstage/core-plugin-api'; -import { - ApiProvider, - ApiRegistry, - AlertApiForwarder, -} from '@backstage/core-app-api'; +import { AlertApiForwarder } from '@backstage/core-app-api'; import Observable from 'zen-observable'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; const TEST_MESSAGE = 'TEST_MESSAGE'; describe('', () => { it('renders without exploding', async () => { - const apiRegistry = ApiRegistry.from([ - [alertApiRef, new AlertApiForwarder()], - ]); - const { queryByText } = await renderInTestApp( - + - , + , ); expect(queryByText(TEST_MESSAGE)).not.toBeInTheDocument(); }); it('renders with message', async () => { - const apiRegistry = ApiRegistry.from([ - [ - alertApiRef, - { - post() {}, - alert$() { - return Observable.of({ message: TEST_MESSAGE }); - }, - }, - ], - ]); - const { queryByText } = await renderInTestApp( - + - , + , ); expect(queryByText(TEST_MESSAGE)).toBeInTheDocument(); }); describe('with multiple messages', () => { - let apiRegistry: ApiRegistry; - - beforeEach(() => { - apiRegistry = ApiRegistry.from([ - [ - alertApiRef, - { - post() {}, - alert$() { - return Observable.of( - { message: 'message one' }, - { message: 'message two' }, - { message: 'message three' }, - ); - }, + const apis = [ + [ + alertApiRef, + { + post() {}, + alert$() { + return Observable.of( + { message: 'message one' }, + { message: 'message two' }, + { message: 'message three' }, + ); }, - ], - ]); - }); + }, + ] as const, + ] as const; it('renders first message', async () => { const { queryByText } = await renderInTestApp( - + - , + , ); expect(queryByText('message one')).toBeInTheDocument(); @@ -96,9 +84,9 @@ describe('', () => { it('renders a count of remaining messages', async () => { const { queryByText } = await renderInTestApp( - + - , + , ); expect(queryByText('(2 older messages)')).toBeInTheDocument(); diff --git a/packages/core-components/src/components/CopyTextButton/CopyTextButton.test.tsx b/packages/core-components/src/components/CopyTextButton/CopyTextButton.test.tsx index b84d3919e8..a9532dd79b 100644 --- a/packages/core-components/src/components/CopyTextButton/CopyTextButton.test.tsx +++ b/packages/core-components/src/components/CopyTextButton/CopyTextButton.test.tsx @@ -17,10 +17,9 @@ import React from 'react'; import { fireEvent } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { CopyTextButton } from './CopyTextButton'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; -import { errorApiRef, ErrorApi } from '@backstage/core-plugin-api'; +import { errorApiRef } from '@backstage/core-plugin-api'; import { useCopyToClipboard } from 'react-use'; jest.mock('popper.js', () => { @@ -51,22 +50,18 @@ const props = { tooltipText: 'mockTooltip', }; -const apiRegistry = ApiRegistry.from([ - [ - errorApiRef, - { - post: jest.fn(), - error$: jest.fn(), - } as ErrorApi, - ], -]); +const mockErrorApi = { + post: jest.fn(), + error$: jest.fn(), +}; +const apis = [[errorApiRef, mockErrorApi] as const] as const; describe('', () => { it('renders without exploding', async () => { const { getByTitle, queryByText } = await renderInTestApp( - + - , + , ); expect(getByTitle('mockTooltip')).toBeInTheDocument(); expect(queryByText('mockTooltip')).not.toBeInTheDocument(); @@ -80,9 +75,9 @@ describe('', () => { spy.mockReturnValue([{}, copy]); const rendered = await renderInTestApp( - + - , + , ); const button = rendered.getByTitle('mockTooltip'); fireEvent.click(button); @@ -101,10 +96,10 @@ describe('', () => { spy.mockReturnValue([{ error }, jest.fn()]); await renderInTestApp( - + - , + , ); - expect(apiRegistry.get(errorApiRef)?.post).toHaveBeenCalledWith(error); + expect(mockErrorApi.post).toHaveBeenCalledWith(error); }); }); diff --git a/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx index e0e9a89c9f..5487d2e7b5 100644 --- a/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx +++ b/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx @@ -18,12 +18,13 @@ import React from 'react'; import { DismissableBanner } from './DismissableBanner'; import Link from '@material-ui/core/Link'; import Typography from '@material-ui/core/Typography'; -import { ApiProvider, ApiRegistry, WebStorage } from '@backstage/core-app-api'; +import { WebStorage } from '@backstage/core-app-api'; import { ErrorApi, storageApiRef, StorageApi, } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; export default { title: 'Feedback/DismissableBanner', @@ -37,47 +38,47 @@ const createWebStorage = (): StorageApi => { return WebStorage.create({ errorApi }); }; -const apis = ApiRegistry.from([[storageApiRef, createWebStorage()]]); +const apis = [[storageApiRef, createWebStorage()] as const]; export const Default = () => (
- + - +
); export const Error = () => (
- + - +
); export const EmojisIncluded = () => (
- + - +
); export const WithLink = () => (
- + @@ -90,29 +91,29 @@ export const WithLink = () => ( variant="info" id="linked_dismissable" /> - +
); export const Fixed = () => (
- + - +
); export const Warning = () => (
- + - +
); diff --git a/packages/core-components/src/components/DismissableBanner/DismissableBanner.test.tsx b/packages/core-components/src/components/DismissableBanner/DismissableBanner.test.tsx index ddc119369b..e62b3fa82d 100644 --- a/packages/core-components/src/components/DismissableBanner/DismissableBanner.test.tsx +++ b/packages/core-components/src/components/DismissableBanner/DismissableBanner.test.tsx @@ -16,13 +16,17 @@ import React from 'react'; import { fireEvent } from '@testing-library/react'; -import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { + renderWithEffects, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { DismissableBanner } from './DismissableBanner'; -import { ApiRegistry, ApiProvider, WebStorage } from '@backstage/core-app-api'; +import { ApiProvider, WebStorage } from '@backstage/core-app-api'; import { storageApiRef, StorageApi } from '@backstage/core-plugin-api'; describe('', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; const createWebStorage = (): StorageApi => { return WebStorage.create({ @@ -31,7 +35,7 @@ describe('', () => { }; beforeEach(() => { - apis = ApiRegistry.from([[storageApiRef, createWebStorage()]]); + apis = TestApiRegistry.from([storageApiRef, createWebStorage()]); }); it('renders the message and the popover', async () => { diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index df1bcfac03..2d00aad827 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -16,8 +16,11 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; -import { MockAnalyticsApi, wrapInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { + MockAnalyticsApi, + TestApiProvider, + wrapInTestApp, +} from '@backstage/test-utils'; import { analyticsApiRef } from '@backstage/core-plugin-api'; import { isExternalUri, Link } from './Link'; import { Route, Routes } from 'react-router'; @@ -48,11 +51,11 @@ describe('', () => { const { getByText } = render( wrapInTestApp( - + {linkText} - , + , ), ); diff --git a/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx b/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx index a1c45bff4d..361ad123bf 100644 --- a/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx +++ b/packages/core-components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx @@ -20,9 +20,9 @@ import { ErrorBoundary } from './ErrorBoundary'; import { MockErrorApi, renderInTestApp, + TestApiProvider, withLogCollector, } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; type BombProps = { @@ -41,25 +41,25 @@ const Bomb = ({ shouldThrow }: BombProps) => { describe('', () => { it('should render error boundary with and without error', async () => { const { error } = await withLogCollector(['error'], async () => { - const apis = ApiRegistry.with(errorApiRef, new MockErrorApi()); + const errorApi = new MockErrorApi(); const { rerender, queryByRole, getByRole, getByText } = await renderInTestApp( - + - , + , ); expect(queryByRole('alert')).not.toBeInTheDocument(); expect(getByText(/working component/i)).toBeInTheDocument(); rerender( - + - , + , ); expect(getByRole('alert')).toBeInTheDocument(); diff --git a/packages/core-components/src/layout/Header/Header.test.tsx b/packages/core-components/src/layout/Header/Header.test.tsx index 69d46cd102..776e39045c 100644 --- a/packages/core-components/src/layout/Header/Header.test.tsx +++ b/packages/core-components/src/layout/Header/Header.test.tsx @@ -15,13 +15,9 @@ */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { Header } from './Header'; -import { - ApiRegistry, - ConfigReader, - ApiProvider, -} from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/core-app-api'; import { configApiRef } from '@backstage/core-plugin-api'; jest.mock('react-helmet', () => { @@ -72,14 +68,12 @@ describe('
', () => { }); it('should use app.title', async () => { - const apiRegistry = ApiRegistry.with( - configApiRef, - new ConfigReader({ app: { title: 'Blah' } }), - ); const rendered = await renderInTestApp( - +
, - , + , ); rendered.getAllByText(/Title | Blah/); }); diff --git a/packages/core-components/src/layout/HomepageTimer/HomepageTimer.test.tsx b/packages/core-components/src/layout/HomepageTimer/HomepageTimer.test.tsx index 1d4bf6954a..3d9dfe70a1 100644 --- a/packages/core-components/src/layout/HomepageTimer/HomepageTimer.test.tsx +++ b/packages/core-components/src/layout/HomepageTimer/HomepageTimer.test.tsx @@ -14,16 +14,12 @@ * limitations under the License. */ -import { renderWithEffects } from '@backstage/test-utils'; +import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; import { HomepageTimer } from './HomepageTimer'; import React from 'react'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core/styles'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/core-app-api'; import { ConfigApi, configApiRef } from '@backstage/core-plugin-api'; it('changes default timezone to GMT', async () => { @@ -41,9 +37,9 @@ it('changes default timezone to GMT', async () => { const rendered = await renderWithEffects( - + - + , ); diff --git a/packages/core-components/src/layout/Sidebar/Bar.test.tsx b/packages/core-components/src/layout/Sidebar/Bar.test.tsx new file mode 100644 index 0000000000..d288aa94c5 --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/Bar.test.tsx @@ -0,0 +1,108 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import BuildRoundedIcon from '@material-ui/icons/BuildRounded'; +import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import MenuBookIcon from '@material-ui/icons/MenuBook'; +import AcUnitIcon from '@material-ui/icons/AcUnit'; +import { Sidebar, SidebarExpandButton } from './Bar'; +import { SidebarItem, SidebarSearchField } from './Items'; +import { SidebarSubmenuItem } from './SidebarSubmenuItem'; +import { SidebarSubmenu } from './SidebarSubmenu'; +import { SidebarPinStateContext } from '.'; + +async function renderScalableSidebar() { + await renderInTestApp( + {} }} + > + + {}} to="/search" /> + {}} text="Catalog"> + + + + + + + + + , + ); +} + +describe('Sidebar', () => { + beforeEach(async () => { + await renderScalableSidebar(); + }); + + describe('Click to Expand', () => { + it('Sidebar should show expanded items when expand button is clicked', async () => { + userEvent.click(screen.getByTestId('sidebar-expand-button')); + expect(await screen.findByText('Create...')).toBeInTheDocument(); + }); + it('Sidebar should not show expanded items when hovered on', async () => { + userEvent.hover(screen.getByTestId('sidebar-root')); + expect(screen.queryByText('Create...')).not.toBeInTheDocument(); + }); + }); + describe('Submenu Items', () => { + it('Extended sidebar with submenu content hidden by default', async () => { + expect(screen.queryByText('Tools')).not.toBeInTheDocument(); + expect(screen.queryByText('Misc')).not.toBeInTheDocument(); + }); + + it('Extended sidebar with submenu content visible when hover over submenu items', async () => { + userEvent.hover(screen.getByTestId('item-with-submenu')); + expect(await screen.findByText('Tools')).toBeInTheDocument(); + expect(await screen.findByText('Misc')).toBeInTheDocument(); + }); + + it('Multicategory item in submenu shows drop down on click', async () => { + userEvent.hover(screen.getByTestId('item-with-submenu')); + userEvent.click(screen.getByText('Misc')); + expect(screen.getByText('dropdown item 1')).toBeInTheDocument(); + expect(screen.getByText('dropdown item 2')).toBeInTheDocument(); + }); + + it('Dropdown item in submenu renders a link when `to` value is provided', async () => { + userEvent.hover(screen.getByTestId('item-with-submenu')); + userEvent.click(screen.getByText('Misc')); + expect(screen.getByText('dropdown item 1').closest('a')).toHaveAttribute( + 'href', + '/dropdownitemlink', + ); + }); + }); +}); diff --git a/packages/core-components/src/layout/Sidebar/Bar.tsx b/packages/core-components/src/layout/Sidebar/Bar.tsx index db85ca77d7..f1d1b73be4 100644 --- a/packages/core-components/src/layout/Sidebar/Bar.tsx +++ b/packages/core-components/src/layout/Sidebar/Bar.tsx @@ -17,11 +17,13 @@ import { makeStyles } from '@material-ui/core/styles'; import useMediaQuery from '@material-ui/core/useMediaQuery'; import clsx from 'clsx'; -import React, { useRef, useState, useContext, PropsWithChildren } from 'react'; +import React, { useState, useContext, PropsWithChildren, useRef } from 'react'; import { sidebarConfig, SidebarContext } from './config'; import { BackstageTheme } from '@backstage/theme'; import { SidebarPinStateContext } from './Page'; import { MobileSidebar } from './MobileSidebar'; +import DoubleArrowRight from './icons/DoubleArrowRight'; +import DoubleArrowLeft from './icons/DoubleArrowLeft'; export type SidebarClassKey = 'drawer' | 'drawerOpen'; @@ -41,7 +43,6 @@ const useStyles = makeStyles( msOverflowStyle: 'none', scrollbarWidth: 'none', width: sidebarConfig.drawerWidthClosed, - borderRight: `1px solid #383838`, transition: theme.transitions.create('width', { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.shortest, @@ -53,6 +54,19 @@ const useStyles = makeStyles( display: 'none', }, }, + expandButton: { + background: 'none', + border: 'none', + color: theme.palette.navigation.color, + width: '100%', + cursor: 'pointer', + position: 'relative', + height: 48, + }, + arrows: { + position: 'absolute', + right: 10, + }, drawerOpen: { width: sidebarConfig.drawerWidthOpen, transition: theme.transitions.create('width', { @@ -73,6 +87,7 @@ enum State { type Props = { openDelayMs?: number; closeDelayMs?: number; + disableExpandOnHover?: boolean; }; const DesktopSidebar = ({ @@ -126,10 +141,20 @@ const DesktopSidebar = ({ const isOpen = (state === State.Open && !isSmallScreen) || isPinned; + // TODO: Generalize + const setOpen = (open: boolean) => { + if (open) { + handleOpen(); + } else { + handleClose(); + } + }; + return (
) => { theme.breakpoints.down('xs'), ); + // TODO: Generalize + const setOpen = () => {}; + return isMobileScreen ? ( {children} @@ -165,3 +194,39 @@ export const Sidebar = ({ children }: React.PropsWithChildren) => { {children} ); }; + +/** + * A button which allows you to expand the sidebar when clicked. + * Use optionally to replace sidebar's expand-on-hover feature with expand-on-click. + * + * @public + */ +export const SidebarExpandButton = () => { + const classes = useStyles(); + const { isOpen, setOpen } = useContext(SidebarContext); + const { isPinned } = useContext(SidebarPinStateContext); + const isSmallScreen = useMediaQuery(theme => + theme.breakpoints.down('md'), + ); + + const handleClick = () => { + setOpen(!isOpen); + }; + + if (isPinned || isSmallScreen) { + return null; + } + + return ( + + ); +}; diff --git a/packages/core-components/src/layout/Sidebar/Items.test.tsx b/packages/core-components/src/layout/Sidebar/Items.test.tsx index 123c30ceae..f4d699d09c 100644 --- a/packages/core-components/src/layout/Sidebar/Items.test.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.test.tsx @@ -20,7 +20,7 @@ import { createEvent, fireEvent, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import HomeIcon from '@material-ui/icons/Home'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import { Sidebar } from './Bar'; +import { Sidebar, SidebarExpandButton } from './Bar'; import { SidebarItem, SidebarSearchField } from './Items'; import { renderHook } from '@testing-library/react-hooks'; import { hexToRgb, makeStyles } from '@material-ui/core/styles'; @@ -44,6 +44,7 @@ async function renderSidebar() { text="Create..." className={result.current.spotlight} /> + , ); userEvent.hover(screen.getByTestId('sidebar-root')); diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index 8270361298..caaab2a84a 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -14,18 +14,21 @@ * limitations under the License. */ -import { IconComponent } from '@backstage/core-plugin-api'; +import { IconComponent, useElementFilter } from '@backstage/core-plugin-api'; import { BackstageTheme } from '@backstage/theme'; import { makeStyles, styled, Theme } from '@material-ui/core/styles'; import Badge from '@material-ui/core/Badge'; import TextField from '@material-ui/core/TextField'; import Typography from '@material-ui/core/Typography'; import { CreateCSSProperties } from '@material-ui/core/styles/withStyles'; +import ArrowRightIcon from '@material-ui/icons/ArrowRight'; import SearchIcon from '@material-ui/icons/Search'; import clsx from 'clsx'; import React, { + Children, forwardRef, KeyboardEventHandler, + PropsWithChildren, ReactNode, useContext, useState, @@ -33,10 +36,16 @@ import React, { import { Link, NavLinkProps, + resolvePath, useLocation, useResolvedPath, } from 'react-router-dom'; -import { sidebarConfig, SidebarContext } from './config'; +import { + sidebarConfig, + SidebarContext, + SidebarItemWithSubmenuContext, +} from './config'; +import { SidebarSubmenu } from './SidebarSubmenu'; export type SidebarItemClassKey = | 'root' @@ -87,6 +96,16 @@ const useStyles = makeStyles( width: drawerWidthOpen, }, }, + highlightable: { + '&:hover': { + background: + theme.palette.navigation.navItem?.hoverBackground ?? '#404040', + }, + }, + highlighted: { + background: + theme.palette.navigation.navItem?.hoverBackground ?? '#404040', + }, label: { // XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs fontWeight: 'bold', @@ -125,6 +144,14 @@ const useStyles = makeStyles( textAlign: 'center', marginRight: theme.spacing(1), }, + closedItemIcon: { + width: '100%', + justifyContent: 'center', + }, + submenuArrow: { + position: 'absolute', + right: 0, + }, selected: { '&$root': { borderLeft: `solid ${selectedIndicatorWidth}px ${theme.palette.navigation.indicator}`, @@ -184,6 +211,12 @@ const useStyles = makeStyles( textAlign: 'center', marginRight: theme.spacing(1), }, + '&$closed': { + width: drawerWidthClosed, + }, + '& $closedItemIcon': { + paddingRight: selectedIndicatorWidth, + }, selected: { '&$root': { borderLeft: `solid ${selectedIndicatorWidth}px ${theme.palette.navigation.indicator}`, @@ -202,16 +235,124 @@ const useStyles = makeStyles( { name: 'BackstageSidebarItem' }, ); +function isSidebarItemWithSubmenuActive( + submenu: ReactNode, + locationPathname: string, +) { + // Item is active if any of submenu items have active paths + const toPathnames: string[] = []; + let isActive = false; + let submenuItems: ReactNode; + Children.forEach(submenu, element => { + if (!React.isValidElement(element)) return; + submenuItems = element.props.children; + }); + Children.forEach(submenuItems, element => { + if (!React.isValidElement(element)) return; + if (element.props.dropdownItems) { + element.props.dropdownItems.map((item: { to: string }) => + toPathnames.push(item.to), + ); + } else if (element.props.to) { + toPathnames.push(element.props.to); + } + }); + isActive = toPathnames.some(to => { + const toPathname = resolvePath(to); + return locationPathname === toPathname.pathname; + }); + return isActive; +} + +const SidebarItemWithSubmenu = ({ + text, + hasNotifications = false, + icon: Icon, + children, +}: PropsWithChildren) => { + const classes = useStyles(); + const [isHoveredOn, setIsHoveredOn] = useState(false); + const { pathname: locationPathname } = useLocation(); + const isActive = isSidebarItemWithSubmenuActive(children, locationPathname); + + const handleMouseEnter = () => { + setIsHoveredOn(true); + }; + const handleMouseLeave = () => { + setIsHoveredOn(false); + }; + + const { isOpen } = useContext(SidebarContext); + const itemIcon = ( + + + + ); + const openContent = ( + <> +
+ {itemIcon} +
+ {text && ( + + {text} + + )} +
{}
+ + ); + const closedContent = itemIcon; + + return ( + +
+
+ {isOpen ? openContent : closedContent} + {!isHoveredOn && ( + + )} +
+ {isHoveredOn && children} +
+
+ ); +}; + type SidebarItemBaseProps = { icon: IconComponent; text?: string; hasNotifications?: boolean; - children?: ReactNode; + disableHighlight?: boolean; className?: string; }; type SidebarItemButtonProps = SidebarItemBaseProps & { onClick: (ev: React.MouseEvent) => void; + children?: ReactNode; }; type SidebarItemLinkProps = SidebarItemBaseProps & { @@ -219,7 +360,21 @@ type SidebarItemLinkProps = SidebarItemBaseProps & { onClick?: (ev: React.MouseEvent) => void; } & NavLinkProps; -type SidebarItemProps = SidebarItemButtonProps | SidebarItemLinkProps; +type SidebarItemWithSubmenuProps = SidebarItemBaseProps & { + to?: string; + onClick?: (ev: React.MouseEvent) => void; + children: ReactNode; +}; + +/** + * SidebarItem with 'to' property will be a clickable link. + * SidebarItem with 'onClick' property and without 'to' property will be a clickable button. + * SidebarItem which wraps a SidebarSubmenu will be a clickable button which opens a submenu. + */ +type SidebarItemProps = + | SidebarItemLinkProps + | SidebarItemButtonProps + | SidebarItemWithSubmenuProps; function isButtonItem( props: SidebarItemProps, @@ -280,6 +435,7 @@ export const SidebarItem = forwardRef((props, ref) => { icon: Icon, text, hasNotifications = false, + disableHighlight = false, onClick, children, className, @@ -297,6 +453,7 @@ export const SidebarItem = forwardRef((props, ref) => { variant="dot" overlap="circular" invisible={!hasNotifications} + className={clsx({ [classes.closedItemIcon]: !isOpen })} > @@ -310,7 +467,7 @@ export const SidebarItem = forwardRef((props, ref) => { {itemIcon}
{text && ( - + {text} )} @@ -327,9 +484,43 @@ export const SidebarItem = forwardRef((props, ref) => { classes.root, isOpen ? classes.open : classes.closed, isButtonItem(props) && classes.buttonItem, + { [classes.highlightable]: !disableHighlight }, ), }; + let hasSubmenu = false; + let submenu: ReactNode; + const componentType = ( + + <> + + ).type; + // Filter children for SidebarSubmenu components + const submenus = useElementFilter(children, elements => + elements.getElements().filter(child => child.type === componentType), + ); + // Error thrown if more than one SidebarSubmenu in a SidebarItem + if (submenus.length > 1) { + throw new Error( + 'Cannot render more than one SidebarSubmenu inside a SidebarItem', + ); + } else if (submenus.length === 1) { + hasSubmenu = true; + submenu = submenus[0]; + } + + if (hasSubmenu) { + return ( + + {submenu} + + ); + } + if (isButtonItem(props)) { return ( + {dropdownItems && showDropDown && ( +
+ {dropdownItems.map((object, key) => ( + + + {object.title} + + + ))} +
+ )} + + ); + } + + return ( +
+ + + + {title} + + +
+ ); +}; diff --git a/packages/core-components/src/layout/Sidebar/config.ts b/packages/core-components/src/layout/Sidebar/config.ts index a90fac9672..9aa2a25d05 100644 --- a/packages/core-components/src/layout/Sidebar/config.ts +++ b/packages/core-components/src/layout/Sidebar/config.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createContext } from 'react'; +import { createContext, Dispatch, SetStateAction } from 'react'; const drawerWidthClosed = 72; const iconPadding = 24; @@ -38,13 +38,43 @@ export const sidebarConfig = { mobileSidebarHeight: 56, }; +export const submenuConfig = { + drawerWidthClosed: 0, + drawerWidthOpen: 202, + // As per NN/g's guidance on timing for exposing hidden content + // See https://www.nngroup.com/articles/timing-exposing-content/ + defaultOpenDelayMs: 100, + defaultCloseDelayMs: 0, + defaultFadeDuration: 200, + logoHeight: 32, + iconContainerWidth: drawerWidthClosed, + iconSize: drawerWidthClosed - iconPadding * 2, + iconPadding, + selectedIndicatorWidth: 3, + userBadgePadding, + userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2, +}; + export const SIDEBAR_INTRO_LOCAL_STORAGE = '@backstage/core/sidebar-intro-dismissed'; export type SidebarContextType = { isOpen: boolean; + setOpen: (open: boolean) => void; }; export const SidebarContext = createContext({ isOpen: false, + setOpen: _open => {}, }); + +export type SidebarItemWithSubmenuContextType = { + isHoveredOn: boolean; + setIsHoveredOn: Dispatch>; +}; + +export const SidebarItemWithSubmenuContext = + createContext({ + isHoveredOn: false, + setIsHoveredOn: () => {}, + }); diff --git a/packages/core-components/src/layout/Sidebar/icons/DoubleArrowLeft.tsx b/packages/core-components/src/layout/Sidebar/icons/DoubleArrowLeft.tsx new file mode 100644 index 0000000000..eb487940a8 --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/icons/DoubleArrowLeft.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import ArrowBackIosIcon from '@material-ui/icons/ArrowBackIos'; + +const useStyles = makeStyles({ + iconContainer: { + display: 'flex', + position: 'relative', + width: '100%', + }, + arrow1: { + right: '6px', + position: 'absolute', + }, +}); + +const DoubleArrowLeft = () => { + const classes = useStyles(); + + return ( +
+
+ +
+
+ +
+
+ ); +}; + +export default DoubleArrowLeft; diff --git a/packages/core-components/src/layout/Sidebar/icons/DoubleArrowRight.tsx b/packages/core-components/src/layout/Sidebar/icons/DoubleArrowRight.tsx new file mode 100644 index 0000000000..e1e9b9476d --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/icons/DoubleArrowRight.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos'; + +const useStyles = makeStyles({ + iconContainer: { + display: 'flex', + position: 'relative', + width: '100%', + }, + arrow1: { + right: '6px', + position: 'absolute', + }, +}); + +const DoubleArrowRight = () => { + const classes = useStyles(); + + return ( +
+
+ +
+
+ +
+
+ ); +}; + +export default DoubleArrowRight; diff --git a/packages/core-components/src/layout/Sidebar/index.ts b/packages/core-components/src/layout/Sidebar/index.ts index 14d8d35b48..7e4baf9dde 100644 --- a/packages/core-components/src/layout/Sidebar/index.ts +++ b/packages/core-components/src/layout/Sidebar/index.ts @@ -14,10 +14,18 @@ * limitations under the License. */ -export { Sidebar } from './Bar'; +export { Sidebar, SidebarExpandButton } from './Bar'; export { MobileSidebar } from './MobileSidebar'; export { SidebarGroup } from './SidebarGroup'; export type { SidebarGroupProps } from './SidebarGroup'; +export { SidebarSubmenuItem } from './SidebarSubmenuItem'; +export { SidebarSubmenu } from './SidebarSubmenu'; +export type { SidebarSubmenuProps } from './SidebarSubmenu'; +export type { + SidebarSubmenuItemProps, + SidebarSubmenuItemDropdownItem, +} from './SidebarSubmenuItem'; +export type { SidebarClassKey } from './Bar'; export { SidebarPage, SidebarPinStateContext } from './Page'; export type { SidebarPinStateContextType, SidebarPageClassKey } from './Page'; export { diff --git a/packages/core-components/src/layout/Sidebar/localStorage.ts b/packages/core-components/src/layout/Sidebar/localStorage.ts index 0e904ee319..78ec355306 100644 --- a/packages/core-components/src/layout/Sidebar/localStorage.ts +++ b/packages/core-components/src/layout/Sidebar/localStorage.ts @@ -24,10 +24,10 @@ export const LocalStorage = { try { value = JSON.parse( window.localStorage.getItem(LocalStorageKeys.SIDEBAR_PIN_STATE) || - 'false', + 'true', ); } catch { - return false; + return true; } return !!value; }, diff --git a/packages/core-plugin-api/CHANGELOG.md b/packages/core-plugin-api/CHANGELOG.md index d92922ddb4..e0050f9f13 100644 --- a/packages/core-plugin-api/CHANGELOG.md +++ b/packages/core-plugin-api/CHANGELOG.md @@ -1,5 +1,35 @@ # @backstage/core-plugin-api +## 0.2.2 + +### Patch Changes + +- b291d0ed7e: Tweaked the logged deprecation warning for `createRouteRef` to hopefully make it more clear. +- bacb94ea8f: Documented the options of each of the extension creation functions. +- Updated dependencies + - @backstage/theme@0.2.14 + +## 0.2.1 + +### Patch Changes + +- 950b36393c: Deprecated `register` option of `createPlugin` and the `outputs` methods of the plugin instance. + + Introduces the `featureFlags` property to define your feature flags instead. + +## 0.2.0 + +### Minor Changes + +- 7e18ed7f29: Removed the unused `UserFlags` type. +- 7df99cdb77: Remove exports of unused types(`RouteOptions` and `RoutePath`). + +### Patch Changes + +- 37ebea2d68: Add deprecation warnings around `title` `icon` and `path` as they are no longer controlled when creating `routeRefs` +- 2dd2a7b2cc: Deprecated the `theme` property on `AppTheme`, replacing it with `Provider`. See https://backstage.io/docs/api/deprecations#app-theme for more details. +- b6a4bacdc4: Deprecated the `Error` and `ErrorContext` types, replacing them with identical `ErrorApiError` and `ErrorApiErrorContext` types. + ## 0.1.13 ### Patch Changes diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 27f3480930..560071e1fa 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -258,6 +258,7 @@ export type BackstagePlugin< getId(): string; output(): PluginOutput[]; getApis(): Iterable; + getFeatureFlags(): Iterable; provide(extension: Extension): T; routes: Routes; externalRoutes: ExternalRoutes; @@ -463,7 +464,7 @@ export type FeatureFlag = { pluginId: string; }; -// @public +// @public @deprecated export type FeatureFlagOutput = { type: 'feature-flag'; name: string; @@ -682,14 +683,20 @@ export type PluginConfig< register?(hooks: PluginHooks): void; routes?: Routes; externalRoutes?: ExternalRoutes; + featureFlags?: PluginFeatureFlagConfig[]; }; // @public +export type PluginFeatureFlagConfig = { + name: string; +}; + +// @public @deprecated export type PluginHooks = { featureFlags: FeatureFlagsHooks; }; -// @public +// @public @deprecated export type PluginOutput = FeatureFlagOutput; // @public diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index 6679e89645..3e07207c9d 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-plugin-api", "description": "Core API used by Backstage plugins", - "version": "0.1.13", + "version": "0.2.2", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/theme": "^0.2.13", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", "@backstage/version-bridge": "^0.1.0", "@material-ui/core": "^4.12.2", @@ -43,9 +43,9 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index ca5ada8478..19da082ee1 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -51,8 +51,27 @@ export type ComponentLoader = export function createRoutableExtension< T extends (props: any) => JSX.Element | null, >(options: { + /** + * A loader for the component that is rendered by this extension. + */ component: () => Promise; + + /** + * The mount point to bind this routable extension to. + * + * If this extension is placed somewhere in the app element tree of a Backstage + * app, callers will be able to route to this extensions by calling, + * `useRouteRef` with this mount point. + */ mountPoint: RouteRef; + + /** + * The name of this extension that will represent it at runtime. It is for example + * used to identify this extension in analytics data. + * + * If possible the name should always be the same as the name of the exported + * variable for this extension. + */ name?: string; }): Extension { const { component, mountPoint, name } = options; @@ -127,7 +146,21 @@ export function createRoutableExtension< */ export function createComponentExtension< T extends (props: any) => JSX.Element | null, ->(options: { component: ComponentLoader; name?: string }): Extension { +>(options: { + /** + * A loader or synchronously supplied component that is rendered by this extension. + */ + component: ComponentLoader; + + /** + * The name of this extension that will represent it at runtime. It is for example + * used to identify this extension in analytics data. + * + * If possible the name should always be the same as the name of the exported + * variable for this extension. + */ + name?: string; +}): Extension { const { component, name } = options; return createReactExtension({ component, name }); } @@ -148,8 +181,23 @@ export function createComponentExtension< export function createReactExtension< T extends (props: any) => JSX.Element | null, >(options: { + /** + * A loader or synchronously supplied component that is rendered by this extension. + */ component: ComponentLoader; + + /** + * Additional component data that is attached to the top-level extension component. + */ data?: Record; + + /** + * The name of this extension that will represent it at runtime. It is for example + * used to identify this extension in analytics data. + * + * If possible the name should always be the same as the name of the exported + * variable for this extension. + */ name?: string; }): Extension { const { data = {}, name } = options; diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx index 4d42a0c969..8795ecdded 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -18,11 +18,8 @@ import { useElementFilter } from './useElementFilter'; import { renderHook } from '@testing-library/react-hooks'; import { attachComponentData } from './componentData'; import { featureFlagsApiRef } from '../apis'; -import { - ApiProvider, - ApiRegistry, - LocalStorageFeatureFlags, -} from '@backstage/core-app-api'; +import { LocalStorageFeatureFlags } from '@backstage/core-app-api'; +import { TestApiProvider } from '@backstage/test-utils'; const WRAPPING_COMPONENT_KEY = 'core.blob.testing'; const INNER_COMPONENT_KEY = 'core.blob2.testing'; @@ -45,9 +42,9 @@ const FeatureFlagComponent = (_props: { attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true); const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); describe('useElementFilter', () => { diff --git a/packages/core-plugin-api/src/plugin/Plugin.test.tsx b/packages/core-plugin-api/src/plugin/Plugin.test.tsx new file mode 100644 index 0000000000..d3758543a1 --- /dev/null +++ b/packages/core-plugin-api/src/plugin/Plugin.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createPlugin } from './Plugin'; + +describe('Plugin Feature Flag', () => { + it('should be able to register and receive feature flags', () => { + expect( + createPlugin({ + id: 'test', + featureFlags: [{ name: 'test' }], + }).getFeatureFlags(), + ).toEqual([{ name: 'test' }]); + + expect( + createPlugin({ + id: 'test', + register({ featureFlags }) { + featureFlags.register('blob'); + }, + }).getFeatureFlags(), + ).toEqual([{ name: 'blob' }]); + + expect( + createPlugin({ + id: 'test', + register({ featureFlags }) { + featureFlags.register('blob'); + }, + featureFlags: [{ name: 'test' }], + }).getFeatureFlags(), + ).toEqual([{ name: 'test' }, { name: 'blob' }]); + + expect( + createPlugin({ + id: 'test', + }).getFeatureFlags(), + ).toEqual([]); + + /* deprecated tests */ + + expect( + createPlugin({ + id: 'test', + featureFlags: [{ name: 'test' }], + }).output(), + ).toEqual([{ name: 'test', type: 'feature-flag' }]); + + expect( + createPlugin({ + id: 'test', + register({ featureFlags }) { + featureFlags.register('blob'); + }, + }).output(), + ).toEqual([{ name: 'blob', type: 'feature-flag' }]); + expect( + createPlugin({ + id: 'test', + register({ featureFlags }) { + featureFlags.register('blob'); + }, + featureFlags: [{ name: 'test' }], + }).output(), + ).toEqual([ + { name: 'test', type: 'feature-flag' }, + { name: 'blob', type: 'feature-flag' }, + ]); + + expect( + createPlugin({ + id: 'test', + }).output(), + ).toEqual([]); + }); +}); diff --git a/packages/core-plugin-api/src/plugin/Plugin.tsx b/packages/core-plugin-api/src/plugin/Plugin.tsx index 52ca0e76e3..0fb7574cd7 100644 --- a/packages/core-plugin-api/src/plugin/Plugin.tsx +++ b/packages/core-plugin-api/src/plugin/Plugin.tsx @@ -21,6 +21,7 @@ import { Extension, AnyRoutes, AnyExternalRoutes, + PluginFeatureFlagConfig, } from './types'; import { AnyApiFactory } from '../apis'; @@ -44,6 +45,14 @@ export class PluginImpl< return this.config.apis ?? []; } + getFeatureFlags(): Iterable { + const registeredFlags = this.output() + .filter(({ type }) => type === 'feature-flag') + .map(({ name }) => ({ name })); + + return registeredFlags; + } + get routes(): Routes { return this.config.routes ?? ({} as Routes); } @@ -56,11 +65,18 @@ export class PluginImpl< if (this.storedOutput) { return this.storedOutput; } - if (!this.config.register) { - return []; + const outputs = new Array(); + this.storedOutput = outputs; + + if (this.config.featureFlags) { + for (const flag of this.config.featureFlags) { + outputs.push({ type: 'feature-flag', name: flag.name }); + } } - const outputs = new Array(); + if (!this.config.register) { + return outputs; + } this.config.register({ featureFlags: { @@ -70,7 +86,6 @@ export class PluginImpl< }, }); - this.storedOutput = outputs; return this.storedOutput; } diff --git a/packages/core-plugin-api/src/plugin/index.ts b/packages/core-plugin-api/src/plugin/index.ts index d3272607ef..4f7609fd9c 100644 --- a/packages/core-plugin-api/src/plugin/index.ts +++ b/packages/core-plugin-api/src/plugin/index.ts @@ -25,4 +25,5 @@ export type { PluginConfig, PluginHooks, PluginOutput, + PluginFeatureFlagConfig, } from './types'; diff --git a/packages/core-plugin-api/src/plugin/types.ts b/packages/core-plugin-api/src/plugin/types.ts index aeb7037c51..0f8d3404d8 100644 --- a/packages/core-plugin-api/src/plugin/types.ts +++ b/packages/core-plugin-api/src/plugin/types.ts @@ -19,7 +19,7 @@ import { AnyApiFactory } from '../apis/system'; /** * Replace with using {@link RouteRef}s. - * + * @deprecated will be removed * @public */ export type FeatureFlagOutput = { @@ -31,6 +31,7 @@ export type FeatureFlagOutput = { * {@link FeatureFlagOutput} type. * * @public + * @deprecated Use {@link BackstagePlugin.getFeatureFlags} instead. */ export type PluginOutput = FeatureFlagOutput; @@ -71,13 +72,30 @@ export type BackstagePlugin< ExternalRoutes extends AnyExternalRoutes = {}, > = { getId(): string; + /** + * @deprecated use getFeatureFlags instead. + * */ output(): PluginOutput[]; getApis(): Iterable; + /** + * Returns all registered feature flags for this plugin. + */ + getFeatureFlags(): Iterable; provide(extension: Extension): T; routes: Routes; externalRoutes: ExternalRoutes; }; +/** + * Plugin feature flag configuration. + * + * @public + */ +export type PluginFeatureFlagConfig = { + /** Feature flag name */ + name: string; +}; + /** * Plugin descriptor type. * @@ -89,14 +107,17 @@ export type PluginConfig< > = { id: string; apis?: Iterable; + /** @deprecated use featureFlags property instead for defining feature flags */ register?(hooks: PluginHooks): void; routes?: Routes; externalRoutes?: ExternalRoutes; + featureFlags?: PluginFeatureFlagConfig[]; }; /** * Holds hooks registered by the plugin. * + * @deprecated - feature flags are now registered in plugin config under featureFlags * @public */ export type PluginHooks = { diff --git a/packages/core-plugin-api/src/routing/RouteRef.ts b/packages/core-plugin-api/src/routing/RouteRef.ts index a926b775e6..8bd3d604f6 100644 --- a/packages/core-plugin-api/src/routing/RouteRef.ts +++ b/packages/core-plugin-api/src/routing/RouteRef.ts @@ -23,7 +23,6 @@ import { } from './types'; import { OldIconComponent } from '../icons/types'; -// TODO(Rugvip): Remove this in the next breaking release, it's exported but unused /** * @deprecated * @internal @@ -49,21 +48,45 @@ export class RouteRefImpl private readonly id: string, readonly params: ParamKeys, private readonly config: { + /** @deprecated */ path?: string; + /** @deprecated */ icon?: OldIconComponent; + /** @deprecated */ title?: string; }, - ) {} + ) { + if (config.path) { + // eslint-disable-next-line no-console + console.warn( + `DEPRECATION WARNING: Passing a path to createRouteRef is deprecated, please remove the path for ${this}.`, + ); + } + + if (config.icon) { + // eslint-disable-next-line no-console + console.warn( + `DEPRECATION WARNING: Passing an icon to createRouteRef is deprecated, please remove the icon for ${this}.`, + ); + } + + if (config.title) { + // eslint-disable-next-line no-console + console.warn( + `DEPRECATION WARNING: Passing a title to createRouteRef is deprecated, please remove the title for ${this}.`, + ); + } + } + + /** @deprecated use `useRouteRef` instead */ + get path() { + return this.config.path ?? ''; + } get icon() { return this.config.icon; } - // TODO(Rugvip): Remove this, routes are looked up via the registry instead - get path() { - return this.config.path ?? ''; - } - get title() { return this.config.title ?? this.id; } diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 7e53d0cde5..599fdf78f3 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,121 @@ # @backstage/create-app +## 0.4.6 + +### Patch Changes + +- 24d2ce03f3: Search Modal now relies on the Search Context to access state and state setter. If you use the SidebarSearchModal as described in the [getting started documentation](https://backstage.io/docs/features/search/getting-started#using-the-search-modal), make sure to update your code with the SearchContextProvider. + + ```diff + export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + + - + + + + + + + + ... + ``` + +- 905dd952ac: Incorporate usage of the tokenManager into the backend created using `create-app`. + + In existing backends, update the `PluginEnvironment` to include a `tokenManager`: + + ```diff + // packages/backend/src/types.ts + + ... + import { + ... + + TokenManager, + } from '@backstage/backend-common'; + + export type PluginEnvironment = { + ... + + tokenManager: TokenManager; + }; + ``` + + Then, create a `ServerTokenManager`. This can either be a `noop` that requires no secret and validates all requests by default, or one that uses a secret from your `app-config.yaml` to generate and validate tokens. + + ```diff + // packages/backend/src/index.ts + + ... + import { + ... + + ServerTokenManager, + } from '@backstage/backend-common'; + ... + + function makeCreateEnv(config: Config) { + ... + // CHOOSE ONE + // TokenManager not requiring a secret + + const tokenManager = ServerTokenManager.noop(); + // OR TokenManager requiring a secret + + const tokenManager = ServerTokenManager.fromConfig(config); + + ... + return (plugin: string): PluginEnvironment => { + ... + - return { logger, cache, database, config, reader, discovery }; + + return { logger, cache, database, config, reader, discovery, tokenManager }; + }; + } + ``` + +## 0.4.5 + +### Patch Changes + +- dcaeaac174: Cleaned out the `peerDependencies` in the published version of the package, making it much quicker to run `npx @backstage/create-app` as it no longer needs to install a long list of unnecessary. +- a5a5d7e1f1: DefaultTechDocsCollator is now included in the search backend, and the Search Page updated with the SearchType component that includes the techdocs type +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- 42ebbc18c0: Bump gitbeaker to the latest version + +## 0.4.4 + +### Patch Changes + +- 4ebc9fd277: Create backstage.json file + + `@backstage/create-app` will create a new `backstage.json` file. At this point, the file will contain a `version` property, representing the version of `@backstage/create-app` used for creating the application. If the backstage's application has been bootstrapped using an older version of `@backstage/create-app`, the `backstage.json` file can be created and kept in sync, together with all the changes of the latest version of backstage, by running the following script: + + ```bash + yarn backstage-cli versions:bump + ``` + +- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers` +- 014cbf8cb9: Migrated the app template use the new `@backstage/app-defaults` for the `createApp` import, since the `createApp` exported by `@backstage/app-core-api` will be removed in the future. + + To migrate an existing application, add the latest version of `@backstage/app-defaults` as a dependency in `packages/app/package.json`, and make the following change to `packages/app/src/App.tsx`: + + ```diff + -import { createApp, FlatRoutes } from '@backstage/core-app-api'; + +import { createApp } from '@backstage/app-defaults'; + +import { FlatRoutes } from '@backstage/core-app-api'; + ``` + +- 2163e83fa2: Refactor and add regression tests for create-app tasks +- Updated dependencies + - @backstage/cli-common@0.1.6 + ## 0.4.3 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 7c1feec683..b2fa1a1acf 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "A CLI that helps you create your own Backstage app", - "version": "0.4.3", + "version": "0.4.6", "private": false, "publishConfig": { "access": "public" @@ -25,10 +25,12 @@ "lint": "backstage-cli lint", "test": "backstage-cli test", "clean": "backstage-cli clean", + "prepack": "node scripts/prepack.js", + "postpack": "node scripts/postpack.js", "start": "nodemon --" }, "dependencies": { - "@backstage/cli-common": "^0.1.5", + "@backstage/cli-common": "^0.1.6", "chalk": "^4.0.0", "commander": "^6.1.0", "fs-extra": "9.1.0", @@ -40,6 +42,7 @@ "devDependencies": { "@types/fs-extra": "^9.0.1", "@types/inquirer": "^7.3.1", + "@types/node": "^14.14.32", "@types/recursive-readdir": "^2.2.0", "mock-fs": "^5.1.1", "ts-node": "^10.0.0" diff --git a/packages/create-app/scripts/postpack.js b/packages/create-app/scripts/postpack.js new file mode 100644 index 0000000000..f13db11e76 --- /dev/null +++ b/packages/create-app/scripts/postpack.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable no-restricted-syntax */ + +const fs = require('fs-extra'); +const path = require('path'); + +async function main() { + const pkgPath = path.resolve(__dirname, '../package.json'); + const pkgBackupPath = path.resolve(__dirname, '../package.json-prepack'); + + try { + await fs.move(pkgBackupPath, pkgPath, { overwrite: true }); + } catch (err) { + console.error(`Failed to restore package.json during postpack, ${err}`); + } +} + +main().catch(err => { + console.error(err.stack); + process.exit(1); +}); diff --git a/packages/create-app/scripts/prepack.js b/packages/create-app/scripts/prepack.js new file mode 100644 index 0000000000..2caf865280 --- /dev/null +++ b/packages/create-app/scripts/prepack.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable no-restricted-syntax */ + +const fs = require('fs-extra'); +const path = require('path'); + +async function main() { + const pkgPath = path.resolve(__dirname, '../package.json'); + const pkgBackupPath = path.resolve(__dirname, '../package.json-prepack'); + + const pkg = await fs.readJson(pkgPath); + await fs.writeJson(pkgBackupPath, pkg, { encoding: 'utf8', spaces: 2 }); + delete pkg.peerDependencies; + await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 }); +} + +main().catch(err => { + console.error(err.stack); + process.exit(1); +}); diff --git a/packages/create-app/src/createApp.test.ts b/packages/create-app/src/createApp.test.ts index 09099e2d15..ee01e8fd42 100644 --- a/packages/create-app/src/createApp.test.ts +++ b/packages/create-app/src/createApp.test.ts @@ -59,7 +59,7 @@ describe('command entrypoint', () => { test('should call expected tasks with no path option', async () => { const cmd = {} as unknown as Command; - await createApp(cmd); + await createApp(cmd, '1.0.0'); expect(checkAppExistsMock).toHaveBeenCalled(); expect(createTemporaryAppFolderMock).toHaveBeenCalled(); expect(templatingMock).toHaveBeenCalled(); @@ -69,7 +69,7 @@ describe('command entrypoint', () => { it('should call expected tasks with path option', async () => { const cmd = { path: 'myDirectory' } as unknown as Command; - await createApp(cmd); + await createApp(cmd, '1.0.0'); expect(checkPathExistsMock).toHaveBeenCalled(); expect(templatingMock).toHaveBeenCalled(); expect(buildAppMock).toHaveBeenCalled(); @@ -77,7 +77,7 @@ describe('command entrypoint', () => { it('should not call `buildAppTask` when `skipInstall` is supplied', async () => { const cmd = { skipInstall: true } as unknown as Command; - await createApp(cmd); + await createApp(cmd, '1.0.0'); expect(buildAppMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/create-app/src/createApp.ts b/packages/create-app/src/createApp.ts index 8823e6c858..d301792380 100644 --- a/packages/create-app/src/createApp.ts +++ b/packages/create-app/src/createApp.ts @@ -30,7 +30,7 @@ import { templatingTask, } from './lib/tasks'; -export default async (cmd: Command): Promise => { +export default async (cmd: Command, version: string): Promise => { /* eslint-disable-next-line no-restricted-syntax */ const paths = findPaths(__dirname); @@ -82,7 +82,7 @@ export default async (cmd: Command): Promise => { await checkPathExistsTask(appDir); Task.section('Preparing files'); - await templatingTask(templateDir, cmd.path, answers); + await templatingTask(templateDir, cmd.path, answers, version); } else { // Template to temporary location, and then move files @@ -93,7 +93,7 @@ export default async (cmd: Command): Promise => { await createTemporaryAppFolderTask(tempDir); Task.section('Preparing files'); - await templatingTask(templateDir, tempDir, answers); + await templatingTask(templateDir, tempDir, answers, version); Task.section('Moving to final location'); await moveAppTask(tempDir, appDir, answers.name); diff --git a/packages/create-app/src/index.ts b/packages/create-app/src/index.ts index 1f37be56d2..8a2da2fad8 100644 --- a/packages/create-app/src/index.ts +++ b/packages/create-app/src/index.ts @@ -38,7 +38,7 @@ const main = (argv: string[]) => { '--skip-install', 'Skip the install and builds steps after creating the app', ) - .action(createApp); + .action(cmd => createApp(cmd, version)); program.parse(argv); }; diff --git a/packages/create-app/src/lib/tasks.test.ts b/packages/create-app/src/lib/tasks.test.ts index d69526cb49..24df97c694 100644 --- a/packages/create-app/src/lib/tasks.test.ts +++ b/packages/create-app/src/lib/tasks.test.ts @@ -195,9 +195,12 @@ describe('templatingTask', () => { name: 'SuperCoolBackstageInstance', dbTypeSqlite: true, }; - await templatingTask(templateDir, destinationDir, context); + await templatingTask(templateDir, destinationDir, context, '1.0.0'); expect(fs.existsSync('templatedApp/package.json')).toBe(true); expect(fs.existsSync('templatedApp/.dockerignore')).toBe(true); + await expect(fs.readJson('templatedApp/backstage.json')).resolves.toEqual({ + version: '1.0.0', + }); // catalog was populated with `context.name` expect( fs.readFileSync('templatedApp/catalog-info.yaml', 'utf-8'), diff --git a/packages/create-app/src/lib/tasks.ts b/packages/create-app/src/lib/tasks.ts index 3961e84753..2793088892 100644 --- a/packages/create-app/src/lib/tasks.ts +++ b/packages/create-app/src/lib/tasks.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { BACKSTAGE_JSON } from '@backstage/cli-common'; import chalk from 'chalk'; import fs from 'fs-extra'; import handlebars from 'handlebars'; @@ -22,6 +23,7 @@ import recursive from 'recursive-readdir'; import { basename, dirname, + join, resolve as resolvePath, relative as relativePath, } from 'path'; @@ -84,6 +86,7 @@ export async function templatingTask( templateDir: string, destinationDir: string, context: any, + version: string, ) { const files = await recursive(templateDir).catch(error => { throw new Error(`Failed to read template directory: ${error.message}`); @@ -133,6 +136,12 @@ export async function templatingTask( }); } } + await Task.forItem('creating', BACKSTAGE_JSON, () => + fs.writeFile( + join(destinationDir, BACKSTAGE_JSON), + `{\n "version": ${JSON.stringify(version)}\n}\n`, + ), + ); } /** diff --git a/packages/create-app/templates/default-app/app-config.production.yaml b/packages/create-app/templates/default-app/app-config.production.yaml index 92f4574fd1..5e36c2319f 100644 --- a/packages/create-app/templates/default-app/app-config.production.yaml +++ b/packages/create-app/templates/default-app/app-config.production.yaml @@ -1,8 +1,8 @@ app: # Should be the same as backend.baseUrl when using the `app-backend` plugin - baseUrl: http://localhost:7000 + baseUrl: http://localhost:7007 backend: - baseUrl: http://localhost:7000 + baseUrl: http://localhost:7007 listen: - port: 7000 + port: 7007 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 9c35c58c33..be144d91f7 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -6,9 +6,14 @@ organization: name: My Company backend: - baseUrl: http://localhost:7000 + # Used for enabling authentication, secret is shared by all backend plugins + # See backend-to-backend-auth.md in the docs for information on the format + # auth: + # keys: + # - secret: ${BACKEND_SECRET} + baseUrl: http://localhost:7007 listen: - port: 7000 + port: 7007 csp: connect-src: ["'self'", 'http:', 'https:'] # Content-Security-Policy directives follow the Helmet format: https://helmetjs.github.io/#reference @@ -32,7 +37,9 @@ backend: user: ${POSTGRES_USER} password: ${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) + # you can set the sslmode configuration option via the `PGSSLMODE` environment variable + # see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require) + # ssl: # ca: # if you have a CA file and want to verify it you can uncomment this section # $file: /ca/server.crt {{/if}} diff --git a/packages/create-app/templates/default-app/packages/app/src/App.test.tsx b/packages/create-app/templates/default-app/packages/app/src/App.test.tsx index 82bc479858..b94cac73b3 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.test.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.test.tsx @@ -10,9 +10,9 @@ describe('App', () => { { data: { app: { title: 'Test' }, - backend: { baseUrl: 'http://localhost:7000' }, + backend: { baseUrl: 'http://localhost:7007' }, techdocs: { - storageUrl: 'http://localhost:7000/api/techdocs/static/docs', + storageUrl: 'http://localhost:7007/api/techdocs/static/docs', }, }, context: 'test', diff --git a/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx b/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx index c540ce7f59..7e98c7d3e6 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx @@ -14,6 +14,24 @@ * limitations under the License. */ +import React, { useContext, PropsWithChildren } from 'react'; +import { Link, makeStyles } from '@material-ui/core'; +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 { NavLink } from 'react-router-dom'; +import { + Settings as SidebarSettings, + UserSettingsSignInAvatar, +} from '@backstage/plugin-user-settings'; +import { + SidebarSearchModal, + SearchContextProvider, +} from '@backstage/plugin-search'; import { Sidebar, sidebarConfig, @@ -25,23 +43,8 @@ import { SidebarScrollWrapper, SidebarSpace, } from '@backstage/core-components'; -import { SidebarSearchModal } from '@backstage/plugin-search'; -import { - Settings as SidebarSettings, - UserSettingsSignInAvatar, -} from '@backstage/plugin-user-settings'; -import { Link, makeStyles } from '@material-ui/core'; -import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import ExtensionIcon from '@material-ui/icons/Extension'; -import HomeIcon from '@material-ui/icons/Home'; -import LibraryBooks from '@material-ui/icons/LibraryBooks'; import MenuIcon from '@material-ui/icons/Menu'; -import MapIcon from '@material-ui/icons/MyLocation'; import SearchIcon from '@material-ui/icons/Search'; -import React, { PropsWithChildren, useContext } from 'react'; -import { NavLink } from 'react-router-dom'; -import LogoFull from './LogoFull'; -import LogoIcon from './LogoIcon'; const useSidebarLogoStyles = makeStyles({ root: { @@ -81,7 +84,9 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( } to="/search"> - + + + {' '} }> diff --git a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx index 7b3c2b29d4..50ffbadb76 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx @@ -2,10 +2,13 @@ import React from 'react'; import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core'; import { CatalogResultListItem } from '@backstage/plugin-catalog'; +import { DocsResultListItem } from '@backstage/plugin-techdocs'; + import { SearchBar, SearchFilter, SearchResult, + SearchType, DefaultResultListItem, } from '@backstage/plugin-search'; import { Content, Header, Page } from '@backstage/core-components'; @@ -39,6 +42,11 @@ const SearchPage = () => { + { result={document} /> ); + case 'techdocs': + return ( + + ); default: return ( { const logger = root.child({ type: 'plugin', plugin }); const database = databaseManager.forPlugin(plugin); const cache = cacheManager.forPlugin(plugin); - return { logger, database, cache, config, reader, discovery }; + return { logger, database, cache, config, reader, discovery, tokenManager }; }; } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts index 7fc317d23d..f23b0c7bcf 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts @@ -6,21 +6,36 @@ import { } from '@backstage/plugin-search-backend-node'; import { PluginEnvironment } from '../types'; import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend'; +import { DefaultTechDocsCollator } from '@backstage/plugin-techdocs-backend'; export default async function createPlugin({ logger, discovery, config, + tokenManager, }: PluginEnvironment) { // Initialize a connection to a search engine. const searchEngine = new LunrSearchEngine({ logger }); const indexBuilder = new IndexBuilder({ logger, searchEngine }); // Collators are responsible for gathering documents known to plugins. This - // particular collator gathers entities from the software catalog. + // collator gathers entities from the software catalog. indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: DefaultCatalogCollator.fromConfig(config, { discovery }), + collator: DefaultCatalogCollator.fromConfig(config, { + discovery, + tokenManager, + }), + }); + + // collator gathers entities from techdocs. + indexBuilder.addCollator({ + defaultRefreshIntervalSeconds: 600, + collator: DefaultTechDocsCollator.fromConfig(config, { + discovery, + logger, + tokenManager, + }), }); // The scheduler controls when documents are gathered from collators and sent diff --git a/packages/create-app/templates/default-app/packages/backend/src/types.ts b/packages/create-app/templates/default-app/packages/backend/src/types.ts index 6c78a2a90c..b1e2e0a1df 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/types.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/types.ts @@ -4,6 +4,7 @@ import { PluginCacheManager, PluginDatabaseManager, PluginEndpointDiscovery, + TokenManager, UrlReader, } from '@backstage/backend-common'; @@ -14,4 +15,5 @@ export type PluginEnvironment = { config: Config; reader: UrlReader; discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; }; diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index 6afb45d79b..b882155a3d 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/dev-utils +## 0.2.13 + +### Patch Changes + +- 58a4e67ded: Add theme switcher to sidebar of dev app. +- 014cbf8cb9: Migrated to using `@backstage/app-defaults`. +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 + - @backstage/test-utils@0.1.22 + - @backstage/app-defaults@0.1.1 + - @backstage/integration-react@0.1.14 + ## 0.2.12 ### Patch Changes diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 974329e8d4..110d774777 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/dev-utils", "description": "Utilities for developing Backstage plugins.", - "version": "0.2.12", + "version": "0.2.13", "private": false, "publishConfig": { "access": "public", @@ -29,15 +29,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/app-defaults": "^0.1.0", - "@backstage/core-app-api": "^0.1.18", - "@backstage/core-components": "^0.7.1", - "@backstage/core-plugin-api": "^0.1.11", - "@backstage/catalog-model": "^0.9.5", - "@backstage/integration-react": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/test-utils": "^0.1.19", - "@backstage/theme": "^0.2.11", + "@backstage/app-defaults": "^0.1.1", + "@backstage/core-app-api": "^0.1.24", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/catalog-model": "^0.9.7", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/test-utils": "^0.1.22", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@testing-library/jest-dom": "^5.10.1", @@ -53,7 +53,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx index 3907dc1007..43eaa6218c 100644 --- a/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx +++ b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; + import { AppThemeApi, appThemeApiRef } from '@backstage/core-plugin-api'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { BackstageTheme } from '@backstage/theme'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -24,7 +24,6 @@ import { SidebarThemeSwitcher } from './SidebarThemeSwitcher'; describe('SidebarThemeSwitcher', () => { let appThemeApi: jest.Mocked; - let apiRegistry: ApiRegistry; beforeEach(() => { appThemeApi = { @@ -51,15 +50,13 @@ describe('SidebarThemeSwitcher', () => { theme: {} as unknown as BackstageTheme, }, ]); - - apiRegistry = ApiRegistry.with(appThemeApiRef, appThemeApi); }); it('should display current theme', async () => { const { getByLabelText, getByRole, getByText } = await renderInTestApp( - + - , + , ); const button = getByLabelText('Switch Theme'); @@ -76,9 +73,9 @@ describe('SidebarThemeSwitcher', () => { it('should select different theme', async () => { const { getByLabelText, getByRole, getByText } = await renderInTestApp( - + - , + , ); const button = getByLabelText('Switch Theme'); diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index e1d8473121..a7456f91dd 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -471,7 +471,7 @@ async function testBackendStart(appDir: string, isPostgres: boolean) { print('Try to fetch entities from the backend'); // Try fetch entities, should be ok - await fetch('http://localhost:7000/api/catalog/entities').then(res => + await fetch('http://localhost:7007/api/catalog/entities').then(res => res.json(), ); print('Entities fetched successfully'); diff --git a/packages/embedded-techdocs-app/CHANGELOG.md b/packages/embedded-techdocs-app/CHANGELOG.md new file mode 100644 index 0000000000..13aa9aabe3 --- /dev/null +++ b/packages/embedded-techdocs-app/CHANGELOG.md @@ -0,0 +1,29 @@ +# embedded-techdocs-app + +## 0.2.55 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/cli@0.10.0 + - @backstage/core-plugin-api@0.2.2 + - @backstage/core-app-api@0.1.24 + - @backstage/plugin-techdocs@0.12.8 + +## 0.2.53 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog@0.7.3 + - @backstage/cli@0.9.0 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-techdocs@0.12.6 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 + - @backstage/test-utils@0.1.22 + - @backstage/app-defaults@0.1.1 + - @backstage/integration-react@0.1.14 diff --git a/packages/embedded-techdocs-app/app-config.dev.yaml b/packages/embedded-techdocs-app/app-config.dev.yaml index 2d1bad2808..02d68c940e 100644 --- a/packages/embedded-techdocs-app/app-config.dev.yaml +++ b/packages/embedded-techdocs-app/app-config.dev.yaml @@ -5,8 +5,8 @@ app: baseUrl: http://localhost:3000 backend: - baseUrl: http://localhost:7000 + baseUrl: http://localhost:7007 techdocs: builder: 'external' - requestUrl: http://localhost:7000/api + requestUrl: http://localhost:7007/api diff --git a/packages/embedded-techdocs-app/package.json b/packages/embedded-techdocs-app/package.json index 7901d46fee..24db320eaa 100644 --- a/packages/embedded-techdocs-app/package.json +++ b/packages/embedded-techdocs-app/package.json @@ -1,21 +1,21 @@ { "name": "embedded-techdocs-app", - "version": "0.0.0", + "version": "0.2.55", "private": true, "bundled": true, "dependencies": { - "@backstage/app-defaults": "^0.1.0", - "@backstage/catalog-model": "^0.9.5", - "@backstage/cli": "^0.8.0", + "@backstage/app-defaults": "^0.1.1", + "@backstage/catalog-model": "^0.9.7", + "@backstage/cli": "^0.10.0", "@backstage/config": "^0.1.10", - "@backstage/core-app-api": "^0.1.18", - "@backstage/core-components": "^0.7.1", - "@backstage/core-plugin-api": "^0.1.11", - "@backstage/integration-react": "^0.1.12", - "@backstage/plugin-catalog": "^0.7.2", - "@backstage/plugin-techdocs": "^0.12.3", - "@backstage/test-utils": "^0.1.19", - "@backstage/theme": "^0.2.11", + "@backstage/core-app-api": "^0.1.24", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-catalog": "^0.7.3", + "@backstage/plugin-techdocs": "^0.12.8", + "@backstage/test-utils": "^0.1.22", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "history": "^5.0.0", @@ -26,7 +26,7 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^089.0", + "@backstage/cli": "^0.10.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/packages/embedded-techdocs-app/src/App.test.tsx b/packages/embedded-techdocs-app/src/App.test.tsx index a5a4374976..f177433d95 100644 --- a/packages/embedded-techdocs-app/src/App.test.tsx +++ b/packages/embedded-techdocs-app/src/App.test.tsx @@ -26,9 +26,9 @@ describe('App', () => { { data: { app: { title: 'Test' }, - backend: { baseUrl: 'http://localhost:7000' }, + backend: { baseUrl: 'http://localhost:7007' }, techdocs: { - storageUrl: 'http://localhost:7000/api/techdocs/static/docs', + storageUrl: 'http://localhost:7007/api/techdocs/static/docs', }, }, context: 'test', diff --git a/packages/errors/CHANGELOG.md b/packages/errors/CHANGELOG.md index 9ea2f47cbb..02b28cdf1d 100644 --- a/packages/errors/CHANGELOG.md +++ b/packages/errors/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/errors +## 0.1.5 + +### Patch Changes + +- 4d09c60256: Deprecate `parseErrorResponse` in favour of `parseErrorResponseBody`. Deprecate `data` field inside `ErrorResponse` in favour of `body`. + Rename the error name for unknown errors from `unknown` to `error`. + ## 0.1.4 ### Patch Changes diff --git a/packages/errors/api-report.md b/packages/errors/api-report.md index 4984919e6a..97b011bad6 100644 --- a/packages/errors/api-report.md +++ b/packages/errors/api-report.md @@ -33,8 +33,11 @@ export type ErrorLike = { [unknownKeys: string]: unknown; }; +// @public @deprecated +export type ErrorResponse = ErrorResponseBody; + // @public -export type ErrorResponse = { +export type ErrorResponseBody = { error: SerializedError; request?: { method: string; @@ -65,19 +68,27 @@ export class NotFoundError extends CustomErrorBase {} // @public export class NotModifiedError extends CustomErrorBase {} -// @public +// @public @deprecated export function parseErrorResponse(response: Response): Promise; +// @public +export function parseErrorResponseBody( + response: Response, +): Promise; + // @public export class ResponseError extends Error { + // @deprecated constructor(props: { message: string; response: Response; - data: ErrorResponse; + data: ErrorResponseBody; cause: Error; }); + readonly body: ErrorResponseBody; readonly cause: Error; - readonly data: ErrorResponse; + // @deprecated + get data(): ErrorResponseBody; static fromResponse(response: Response): Promise; readonly response: Response; } diff --git a/packages/errors/package.json b/packages/errors/package.json index fe62bb3bba..d0d59ffd6a 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/errors", "description": "Common utilities for error handling within Backstage", - "version": "0.1.4", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -35,7 +35,7 @@ "serialize-error": "^8.0.1" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.10.0", "@types/jest": "^26.0.7" }, "files": [ diff --git a/packages/errors/src/errors/ResponseError.ts b/packages/errors/src/errors/ResponseError.ts index f74ad2fa0c..a20035cd8c 100644 --- a/packages/errors/src/errors/ResponseError.ts +++ b/packages/errors/src/errors/ResponseError.ts @@ -14,16 +14,16 @@ * limitations under the License. */ +import { deserializeError } from '../serialization'; import { - parseErrorResponse, - ErrorResponse, - deserializeError, -} from '../serialization'; + ErrorResponseBody, + parseErrorResponseBody, +} from '../serialization/response'; /** * An error thrown as the result of a failed server request. * - * The server is expected to respond on the ErrorResponse format. + * The server is expected to respond on the ErrorResponseBody format. * * @public */ @@ -39,7 +39,7 @@ export class ResponseError extends Error { /** * The parsed JSON error body, as sent by the server. */ - readonly data: ErrorResponse; + readonly body: ErrorResponseBody; /** * The Error cause, as seen by the remote server. This is parsed out of the @@ -60,7 +60,7 @@ export class ResponseError extends Error { * been consumed before. */ static async fromResponse(response: Response): Promise { - const data = await parseErrorResponse(response); + const data = await parseErrorResponseBody(response); const status = data.response.statusCode || response.status; const statusText = data.error.name || response.statusText; @@ -75,16 +75,28 @@ export class ResponseError extends Error { }); } + /** + * @deprecated will be removed. + **/ constructor(props: { message: string; response: Response; - data: ErrorResponse; + data: ErrorResponseBody; cause: Error; }) { super(props.message); this.name = 'ResponseError'; this.response = props.response; - this.data = props.data; + this.body = props.data; this.cause = props.cause; } + /** + * The parsed JSON error body, as sent by the server. + * @deprecated use body instead. + */ + get data(): ErrorResponseBody { + // eslint-disable-next-line no-console + console.warn('ErrorResponse.data is deprecated, use .body instead.'); + return this.body; + } } diff --git a/packages/errors/src/serialization/index.ts b/packages/errors/src/serialization/index.ts index d37b04297a..b86661d496 100644 --- a/packages/errors/src/serialization/index.ts +++ b/packages/errors/src/serialization/index.ts @@ -16,5 +16,5 @@ export { deserializeError, serializeError, stringifyError } from './error'; export type { SerializedError } from './error'; -export { parseErrorResponse } from './response'; -export type { ErrorResponse } from './response'; +export { parseErrorResponse, parseErrorResponseBody } from './response'; +export type { ErrorResponse, ErrorResponseBody } from './response'; diff --git a/packages/errors/src/serialization/response.test.ts b/packages/errors/src/serialization/response.test.ts index 54a3641657..fe1cc1e9c4 100644 --- a/packages/errors/src/serialization/response.test.ts +++ b/packages/errors/src/serialization/response.test.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { parseErrorResponse, ErrorResponse } from './response'; +import { parseErrorResponseBody, ErrorResponseBody } from './response'; -describe('parseErrorResponse', () => { +describe('parseErrorResponseBody', () => { it('handles the happy path', async () => { - const body: ErrorResponse = { + const body: ErrorResponseBody = { error: { name: 'Fours', message: 'Expected fives', stack: 'lines' }, request: { method: 'GET', url: '/' }, response: { statusCode: 444 }, @@ -31,13 +31,13 @@ describe('parseErrorResponse', () => { headers: new Headers({ 'Content-Type': 'application/json' }), }; - await expect(parseErrorResponse(response as Response)).resolves.toEqual( + await expect(parseErrorResponseBody(response as Response)).resolves.toEqual( body, ); }); it('uses request header and text body when wrong content type, even if parsable', async () => { - const body: ErrorResponse = { + const body: ErrorResponseBody = { error: { name: 'Threes', message: 'Expected twos' }, request: { method: 'GET', url: '/' }, response: { statusCode: 333 }, @@ -50,19 +50,21 @@ describe('parseErrorResponse', () => { headers: new Headers({ 'Content-Type': 'not-application/not-json' }), }; - await expect(parseErrorResponse(response as Response)).resolves.toEqual({ - error: { - name: 'Unknown', - message: `Request failed with status 444 Fours, ${JSON.stringify( - body, - )}`, + await expect(parseErrorResponseBody(response as Response)).resolves.toEqual( + { + error: { + name: 'Error', + message: `Request failed with status 444 Fours, ${JSON.stringify( + body, + )}`, + }, + response: { statusCode: 444 }, }, - response: { statusCode: 444 }, - }); + ); }); it('uses request header and text body when not parsable', async () => { - const body: ErrorResponse = { + const body: ErrorResponseBody = { error: { name: 'Threes', message: 'Expected twos' }, request: { method: 'GET', url: '/' }, response: { statusCode: 333 }, @@ -75,15 +77,17 @@ describe('parseErrorResponse', () => { headers: new Headers({ 'Content-Type': 'application/json' }), }; - await expect(parseErrorResponse(response as Response)).resolves.toEqual({ - error: { - name: 'Unknown', - message: `Request failed with status 444 Fours, ${JSON.stringify( - body, - ).substring(1)}`, + await expect(parseErrorResponseBody(response as Response)).resolves.toEqual( + { + error: { + name: 'Error', + message: `Request failed with status 444 Fours, ${JSON.stringify( + body, + ).substring(1)}`, + }, + response: { statusCode: 444 }, }, - response: { statusCode: 444 }, - }); + ); }); it('uses request header when failing to get body', async () => { @@ -96,12 +100,14 @@ describe('parseErrorResponse', () => { headers: new Headers({ 'Content-Type': 'application/json' }), }; - await expect(parseErrorResponse(response as Response)).resolves.toEqual({ - error: { - name: 'Unknown', - message: `Request failed with status 444 Fours`, + await expect(parseErrorResponseBody(response as Response)).resolves.toEqual( + { + error: { + name: 'Error', + message: `Request failed with status 444 Fours`, + }, + response: { statusCode: 444 }, }, - response: { statusCode: 444 }, - }); + ); }); }); diff --git a/packages/errors/src/serialization/response.ts b/packages/errors/src/serialization/response.ts index 50bf8f7ab1..4a1731646d 100644 --- a/packages/errors/src/serialization/response.ts +++ b/packages/errors/src/serialization/response.ts @@ -20,8 +20,16 @@ import { SerializedError } from './error'; * A standard shape of JSON data returned as the body of backend errors. * * @public + * @deprecated - Use {@link ErrorResponseBody} instead. */ -export type ErrorResponse = { +export type ErrorResponse = ErrorResponseBody; + +/** + * A standard shape of JSON data returned as the body of backend errors. + * + * @public + */ +export type ErrorResponseBody = { /** Details of the error that was caught */ error: SerializedError; @@ -51,10 +59,29 @@ export type ErrorResponse = { * * @public * @param response - The response of a failed request + * @deprecated - Use {@link parseErrorResponseBody} instead. */ export async function parseErrorResponse( response: Response, ): Promise { + return parseErrorResponseBody(response); +} + +/** + * Attempts to construct an ErrorResponseBody out of a failed server request. + * Assumes that the response has already been checked to be not ok. This + * function consumes the body of the response, and assumes that it hasn't + * been consumed before. + * + * The code is forgiving, and constructs a useful synthetic body as best it can + * if the response body wasn't on the expected form. + * + * @public + * @param response - The response of a failed request + */ +export async function parseErrorResponseBody( + response: Response, +): Promise { try { const text = await response.text(); if (text) { @@ -73,7 +100,7 @@ export async function parseErrorResponse( return { error: { - name: 'Unknown', + name: 'Error', message: `Request failed with status ${response.status} ${response.statusText}, ${text}`, }, response: { @@ -87,7 +114,7 @@ export async function parseErrorResponse( return { error: { - name: 'Unknown', + name: 'Error', message: `Request failed with status ${response.status} ${response.statusText}`, }, response: { diff --git a/packages/integration-react/CHANGELOG.md b/packages/integration-react/CHANGELOG.md index a2a0d0c368..e972b4d4e8 100644 --- a/packages/integration-react/CHANGELOG.md +++ b/packages/integration-react/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/integration-react +## 0.1.14 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.13 ### Patch Changes diff --git a/packages/integration-react/package.json b/packages/integration-react/package.json index b703203ab2..be38d92b64 100644 --- a/packages/integration-react/package.json +++ b/packages/integration-react/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/integration-react", "description": "Frontend package for managing integrations towards external systems", - "version": "0.1.13", + "version": "0.1.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,10 +22,10 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.2", - "@backstage/core-plugin-api": "^0.1.12", - "@backstage/integration": "^0.6.9", - "@backstage/theme": "^0.2.12", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/integration": "^0.6.10", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -34,9 +34,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.10.0", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/packages/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index b9c6601e6c..e3380082f7 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/integration +## 0.6.10 + +### Patch Changes + +- 47619da24c: Narrow the types returned by the request option functions, to only the specifics that they actually do return. The reason for this change is that a full `RequestInit` is unfortunate to return because it's different between `cross-fetch` and `node-fetch`. + ## 0.6.9 ### Patch Changes diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 4c2258779a..fcaa39b285 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -112,7 +112,9 @@ export function getAzureFileFetchUrl(url: string): string; export function getAzureRequestOptions( config: AzureIntegrationConfig, additionalHeaders?: Record, -): RequestInit; +): { + headers: Record; +}; // @public export function getBitbucketDefaultBranch( @@ -135,7 +137,9 @@ export function getBitbucketFileFetchUrl( // @public export function getBitbucketRequestOptions( config: BitbucketIntegrationConfig, -): RequestInit; +): { + headers: Record; +}; // @public export function getGitHubFileFetchUrl( @@ -148,7 +152,9 @@ export function getGitHubFileFetchUrl( export function getGitHubRequestOptions( config: GitHubIntegrationConfig, credentials: GithubCredentials, -): RequestInit; +): { + headers: Record; +}; // @public export function getGitLabFileFetchUrl( @@ -157,9 +163,9 @@ export function getGitLabFileFetchUrl( ): Promise; // @public -export function getGitLabRequestOptions( - config: GitLabIntegrationConfig, -): RequestInit; +export function getGitLabRequestOptions(config: GitLabIntegrationConfig): { + headers: Record; +}; // @public export type GithubAppConfig = { diff --git a/packages/integration/package.json b/packages/integration/package.json index bd49ecc419..a6d91511c3 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/integration", "description": "Helpers for managing integrations towards external systems", - "version": "0.6.9", + "version": "0.6.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -39,9 +39,9 @@ "lodash": "^4.17.21" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/config-loader": "^0.7.1", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.10.0", + "@backstage/config-loader": "^0.8.1", + "@backstage/test-utils": "^0.1.22", "@types/jest": "^26.0.7", "@types/luxon": "^2.0.4", "msw": "^0.35.0" diff --git a/packages/integration/src/azure/core.ts b/packages/integration/src/azure/core.ts index 7461954267..1d5c4c3c89 100644 --- a/packages/integration/src/azure/core.ts +++ b/packages/integration/src/azure/core.ts @@ -64,8 +64,8 @@ export function getAzureCommitsUrl(url: string): string { export function getAzureRequestOptions( config: AzureIntegrationConfig, additionalHeaders?: Record, -): RequestInit { - const headers: HeadersInit = additionalHeaders +): { headers: Record } { + const headers: Record = additionalHeaders ? { ...additionalHeaders } : {}; diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index afcb9fcfdb..da10152bf8 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -158,8 +158,8 @@ export function getBitbucketFileFetchUrl( */ export function getBitbucketRequestOptions( config: BitbucketIntegrationConfig, -): RequestInit { - const headers: HeadersInit = {}; +): { headers: Record } { + const headers: Record = {}; if (config.token) { headers.Authorization = `Bearer ${config.token}`; diff --git a/packages/integration/src/github/core.ts b/packages/integration/src/github/core.ts index 2c5e739a26..76e54fd544 100644 --- a/packages/integration/src/github/core.ts +++ b/packages/integration/src/github/core.ts @@ -73,8 +73,8 @@ export function getGitHubFileFetchUrl( export function getGitHubRequestOptions( config: GitHubIntegrationConfig, credentials: GithubCredentials, -): RequestInit { - const headers: HeadersInit = {}; +): { headers: Record } { + const headers: Record = {}; if (chooseEndpoint(config, credentials) === 'api') { headers.Accept = 'application/vnd.github.v3.raw'; diff --git a/packages/integration/src/gitlab/core.ts b/packages/integration/src/gitlab/core.ts index e6f4dbc910..c5b0713340 100644 --- a/packages/integration/src/gitlab/core.ts +++ b/packages/integration/src/gitlab/core.ts @@ -54,9 +54,9 @@ export async function getGitLabFileFetchUrl( * @param config - The relevant provider config * @public */ -export function getGitLabRequestOptions( - config: GitLabIntegrationConfig, -): RequestInit { +export function getGitLabRequestOptions(config: GitLabIntegrationConfig): { + headers: Record; +} { const { token = '' } = config; return { headers: { diff --git a/packages/search-common/package.json b/packages/search-common/package.json index e913ef66ff..51cea5a28e 100644 --- a/packages/search-common/package.json +++ b/packages/search-common/package.json @@ -39,7 +39,7 @@ "@backstage/types": "^0.1.1" }, "devDependencies": { - "@backstage/cli": "^0.8.1" + "@backstage/cli": "^0.10.0" }, "jest": { "roots": [ diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 41b8433594..4c0813a8e9 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -1,6 +1,5 @@ import { AlertApiForwarder, - ApiRegistry, ErrorAlerter, ErrorApiForwarder, GithubAuth, @@ -11,6 +10,7 @@ import { OktaAuth, Auth0Auth, ConfigReader, + LocalStorageFeatureFlags, } from '@backstage/core-app-api'; import { @@ -25,80 +25,62 @@ import { oktaAuthApiRef, auth0AuthApiRef, configApiRef, + featureFlagsApiRef, } from '@backstage/core-plugin-api'; -const builder = ApiRegistry.builder(); - -builder.add(configApiRef, new ConfigReader({})); - -const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); - -builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); - -builder.add(identityApiRef, { +const configApi = new ConfigReader({}); +const featureFlagsApi = new LocalStorageFeatureFlags(); +const alertApi = new AlertApiForwarder(); +const errorApi = new ErrorAlerter(alertApi, new ErrorApiForwarder()); +const identityApi = { getUserId: () => 'guest', getProfile: () => ({ email: 'guest@example.com' }), getIdToken: () => undefined, signOut: async () => {}, +}; +const oauthRequestApi = new OAuthRequestManager(); +const googleAuthApi = GoogleAuth.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, +}); +const githubAuthApi = GithubAuth.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, +}); +const gitlabAuthApi = GitlabAuth.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, +}); +const oktaAuthApi = OktaAuth.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, +}); +const auth0AuthApi = Auth0Auth.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, +}); +const oauth2Api = OAuth2.create({ + apiOrigin: 'http://localhost:7007', + basePath: '/auth/', + oauthRequestApi, }); -const oauthRequestApi = builder.add( - oauthRequestApiRef, - new OAuthRequestManager(), -); - -builder.add( - googleAuthApiRef, - GoogleAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -builder.add( - githubAuthApiRef, - GithubAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -builder.add( - gitlabAuthApiRef, - GitlabAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -builder.add( - oktaAuthApiRef, - OktaAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -builder.add( - auth0AuthApiRef, - Auth0Auth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -builder.add( - oauth2ApiRef, - OAuth2.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); - -export const apis = builder.build(); +export const apis = [ + [configApiRef, configApi], + [featureFlagsApiRef, featureFlagsApi], + [alertApiRef, alertApi], + [errorApiRef, errorApi], + [identityApiRef, identityApi], + [oauthRequestApiRef, oauthRequestApi], + [googleAuthApiRef, googleAuthApi], + [githubAuthApiRef, githubAuthApi], + [gitlabAuthApiRef, gitlabAuthApi], + [oktaAuthApiRef, oktaAuthApi], + [auth0AuthApiRef, auth0AuthApi], + [oauth2ApiRef, oauth2Api], +]; diff --git a/packages/storybook/.storybook/preview.js b/packages/storybook/.storybook/preview.js index 6186b82ca5..2b1a4af329 100644 --- a/packages/storybook/.storybook/preview.js +++ b/packages/storybook/.storybook/preview.js @@ -6,17 +6,17 @@ import { useDarkMode } from 'storybook-dark-mode'; import { apis } from './apis'; import { Content, AlertDisplay } from '@backstage/core-components'; -import { ApiProvider } from '@backstage/core-app-api'; +import { TestApiProvider } from '@backstage/test-utils'; addDecorator(story => ( - + {story()} - + )); addParameters({ diff --git a/packages/techdocs-cli/.snyk b/packages/techdocs-cli/.snyk new file mode 100644 index 0000000000..cfb30e5aa7 --- /dev/null +++ b/packages/techdocs-cli/.snyk @@ -0,0 +1,17 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.22.1 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JS-BROWSERSLIST-1090194: + - '*': + reason: Developer tools are not a valid target for ReDoS attacks + expires: 2022-05-20T00:00:00.000Z + created: 2021-11-20T00:00:00.000Z + + SNYK-JS-IMMER-1540542: + - '*': + reason: Prototype pollution is not an effective attack against a CLI as it already executes arbitrary code + expires: 2022-05-20T00:00:00.000Z + created: 2021-11-20T00:00:00.000Z + +patch: {} diff --git a/packages/techdocs-cli/CHANGELOG.md b/packages/techdocs-cli/CHANGELOG.md index 0f7c6be52f..d07361e915 100644 --- a/packages/techdocs-cli/CHANGELOG.md +++ b/packages/techdocs-cli/CHANGELOG.md @@ -1,5 +1,25 @@ # @techdocs/cli +## 0.8.7 + +### Patch Changes + +- e7230ef814: Bump react-dev-utils to v12 +- Updated dependencies + - @backstage/backend-common@0.9.12 + +## 0.8.6 + +### Patch Changes + +- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers` +- 1578ad341b: Add support for specifying bucketRootPath for AWS and GCS publishers +- f2694e3750: Adds ability to use encrypted S3 buckets by utilizing the SSE option in the AWS SDK +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + - @backstage/techdocs-common@0.10.7 + ## 0.8.5 ### Patch Changes diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json index a24a1be0f8..34d67d4dc9 100644 --- a/packages/techdocs-cli/package.json +++ b/packages/techdocs-cli/package.json @@ -1,7 +1,7 @@ { "name": "@techdocs/cli", "description": "Utility CLI for managing TechDocs sites in Backstage.", - "version": "0.8.5", + "version": "0.8.7", "private": false, "publishConfig": { "access": "public" @@ -32,7 +32,7 @@ "techdocs-cli": "bin/techdocs-cli" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/commander": "^2.12.2", "@types/fs-extra": "^9.0.6", "@types/http-proxy": "^1.17.4", @@ -41,7 +41,7 @@ "@types/react-dev-utils": "^9.0.4", "@types/serve-handler": "^6.1.0", "@types/webpack-env": "^1.15.3", - "embedded-techdocs-app": "0.0.0", + "embedded-techdocs-app": "0.2.55", "find-process": "^1.4.5", "nodemon": "^2.0.2", "ts-node": "^10.0.0" @@ -56,16 +56,16 @@ "ext": "ts" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/techdocs-common": "^0.10.4", - "@types/dockerode": "^3.2.1", + "@backstage/techdocs-common": "^0.10.7", + "@types/dockerode": "^3.3.0", "commander": "^6.1.0", - "dockerode": "^3.2.1", + "dockerode": "^3.3.1", "fs-extra": "^9.0.1", "http-proxy": "^1.18.1", - "react-dev-utils": "^11.0.4", + "react-dev-utils": "^12.0.0-next.47", "serve-handler": "^6.1.3", "winston": "^3.2.1" } diff --git a/packages/techdocs-cli/src/commands/index.ts b/packages/techdocs-cli/src/commands/index.ts index 280072cfd9..d4f41cf7a3 100644 --- a/packages/techdocs-cli/src/commands/index.ts +++ b/packages/techdocs-cli/src/commands/index.ts @@ -161,6 +161,7 @@ export function registerCommands(program: CommanderStatic) { '--awsEndpoint ', 'Optional AWS endpoint to send requests to.', ) + .option('--awsS3sse ', 'Optional AWS S3 Server Side Encryption.') .option( '--awsS3ForcePathStyle', 'Optional AWS S3 option to force path style.', diff --git a/packages/techdocs-cli/src/commands/serve/serve.ts b/packages/techdocs-cli/src/commands/serve/serve.ts index 1182c850b3..4d71de3e52 100644 --- a/packages/techdocs-cli/src/commands/serve/serve.ts +++ b/packages/techdocs-cli/src/commands/serve/serve.ts @@ -36,7 +36,7 @@ export default async function serve(cmd: Command) { // a backstage app, we define app.baseUrl in the app-config.yaml. // Hence, it is complicated to make this configurable. const backstagePort = 3000; - const backstageBackendPort = 7000; + const backstageBackendPort = 7007; const mkdocsDockerAddr = `http://0.0.0.0:${cmd.mkdocsPort}`; const mkdocsLocalAddr = `http://127.0.0.1:${cmd.mkdocsPort}`; diff --git a/packages/techdocs-cli/src/lib/PublisherConfig.test.ts b/packages/techdocs-cli/src/lib/PublisherConfig.test.ts index 519972c03d..88f542c049 100644 --- a/packages/techdocs-cli/src/lib/PublisherConfig.test.ts +++ b/packages/techdocs-cli/src/lib/PublisherConfig.test.ts @@ -80,6 +80,20 @@ describe('getValidPublisherConfig', () => { actualConfig.getString('techdocs.publisher.awsS3.bucketRootPath'), ).toBe('backstage-data/techdocs'); }); + + it('should return valid ConfigReader with SSE option', () => { + const config = { + publisherType: 'awsS3', + storageName: 'someStorageName', + awsS3sse: 'aws:kms', + } as unknown as Command; + + const actualConfig = PublisherConfig.getValidConfig(config); + expect(actualConfig.getString('techdocs.publisher.type')).toBe('awsS3'); + expect(actualConfig.getString('techdocs.publisher.awsS3.sse')).toBe( + 'aws:kms', + ); + }); }); describe('for openStackSwift', () => { diff --git a/packages/techdocs-cli/src/lib/PublisherConfig.ts b/packages/techdocs-cli/src/lib/PublisherConfig.ts index e855d573ce..c18b9ff588 100644 --- a/packages/techdocs-cli/src/lib/PublisherConfig.ts +++ b/packages/techdocs-cli/src/lib/PublisherConfig.ts @@ -55,9 +55,9 @@ export class PublisherConfig { return new ConfigReader({ // This backend config is not used at all. Just something needed a create a mock discovery instance. backend: { - baseUrl: 'http://localhost:7000', + baseUrl: 'http://localhost:7007', listen: { - port: 7000, + port: 7007, }, }, techdocs: { @@ -89,6 +89,7 @@ export class PublisherConfig { ...(cmd.awsRoleArn && { credentials: { roleArn: cmd.awsRoleArn } }), ...(cmd.awsEndpoint && { endpoint: cmd.awsEndpoint }), ...(cmd.awsS3ForcePathStyle && { s3ForcePathStyle: true }), + ...(cmd.awsS3sse && { sse: cmd.awsS3sse }), }, }; } diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 87e9e12193..386c584196 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,37 @@ # @backstage/techdocs-common +## 0.10.8 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/backend-common@0.9.11 + +## 0.10.7 + +### Patch Changes + +- 0b60a051c9: Added OpenStack Swift case migration support. +- 9e64a7ac1e: Allow amazon web services s3 buckets to pass an server side encryption configuration so they can publish to encrypted buckets +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + ## 0.10.6 ### Patch Changes @@ -855,7 +887,7 @@ Based on the config `techdocs.publisher.type`, the publisher could be either Local publisher or Google Cloud Storage publisher. - 4. `techdocs.storageUrl` is now a required config. Should be `http://localhost:7000/api/techdocs/static/docs` in most setups. + 4. `techdocs.storageUrl` is now a required config. Should be `http://localhost:7007/api/techdocs/static/docs` in most setups. 5. Parts of `@backstage/plugin-techdocs-backend` have been moved to a new package `@backstage/techdocs-common` to generate docs. Also to publish docs to-and-fro between TechDocs and a storage (either local or external). However, a Backstage app does NOT need to import the `techdocs-common` package - diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index b44e68374d..e6956b87e6 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.10.6", + "version": "0.10.8", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -38,12 +38,12 @@ "dependencies": { "@azure/identity": "^1.5.0", "@azure/storage-blob": "^12.5.0", - "@backstage/backend-common": "^0.9.9", - "@backstage/catalog-model": "^0.9.6", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/errors": "^0.1.4", + "@backstage/errors": "^0.1.5", "@backstage/search-common": "^0.2.1", - "@backstage/integration": "^0.6.9", + "@backstage/integration": "^0.6.10", "@google-cloud/storage": "^5.6.0", "@trendyol-js/openstack-swift-sdk": "^0.0.5", "@types/express": "^4.17.6", @@ -60,7 +60,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.2", + "@backstage/cli": "^0.10.0", "@types/fs-extra": "^9.0.5", "@types/js-yaml": "^4.0.0", "@types/mime-types": "^2.1.0", diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index ff88f12939..1f49668d35 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -54,7 +54,7 @@ const createPublisherFromConfig = ({ } = {}) => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'awsS3', awsS3: { diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 1bf3586266..a2503bd7b2 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -51,7 +51,7 @@ const createPublisherFromConfig = ({ } = {}) => { const config = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'azureBlobStorage', azureBlobStorage: { diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index c085594325..a52fd0f0bd 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -52,7 +52,7 @@ const createPublisherFromConfig = ({ } = {}) => { const config = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'googleGcs', googleGcs: { diff --git a/packages/techdocs-common/src/stages/publish/local.test.ts b/packages/techdocs-common/src/stages/publish/local.test.ts index de56580e02..761de03c5a 100644 --- a/packages/techdocs-common/src/stages/publish/local.test.ts +++ b/packages/techdocs-common/src/stages/publish/local.test.ts @@ -39,7 +39,7 @@ const createMockEntity = (annotations = {}, lowerCase = false) => { }; const testDiscovery: jest.Mocked = { - getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/api/techdocs'), + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007/api/techdocs'), getExternalBaseUrl: jest.fn(), }; diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts index 8dde687997..c2236fb880 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts @@ -84,7 +84,7 @@ beforeEach(() => { mockFs.restore(); const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'openStackSwift', openStackSwift: { @@ -114,7 +114,7 @@ describe('OpenStackSwiftPublish', () => { it('should reject incorrect config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'openStackSwift', openStackSwift: { diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index 2671ef7b47..a47da828a7 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -27,7 +27,7 @@ import { OpenStackSwiftPublish } from './openStackSwift'; const logger = getVoidLogger(); const discovery: jest.Mocked = { - getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7000'), + getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7007'), getExternalBaseUrl: jest.fn(), }; @@ -39,7 +39,7 @@ describe('Publisher', () => { it('should create local publisher by default', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', }, }); @@ -53,7 +53,7 @@ describe('Publisher', () => { it('should create local publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'local', }, @@ -70,7 +70,7 @@ describe('Publisher', () => { it('should create google gcs publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'googleGcs', googleGcs: { @@ -91,7 +91,7 @@ describe('Publisher', () => { it('should create AWS S3 publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'awsS3', awsS3: { @@ -115,7 +115,7 @@ describe('Publisher', () => { it('should create Azure Blob Storage publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'azureBlobStorage', azureBlobStorage: { @@ -143,7 +143,7 @@ describe('Publisher', () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'azureBlobStorage', azureBlobStorage: { @@ -166,7 +166,7 @@ describe('Publisher', () => { it('should create Open Stack Swift publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { - requestUrl: 'http://localhost:7000', + requestUrl: 'http://localhost:7007', publisher: { type: 'openStackSwift', openStackSwift: { diff --git a/packages/test-utils-core/.npmrc b/packages/test-utils-core/.npmrc deleted file mode 100644 index 214c29d139..0000000000 --- a/packages/test-utils-core/.npmrc +++ /dev/null @@ -1 +0,0 @@ -registry=https://registry.npmjs.org/ diff --git a/packages/test-utils-core/.snyk b/packages/test-utils-core/.snyk deleted file mode 100644 index f368a3ddb9..0000000000 --- a/packages/test-utils-core/.snyk +++ /dev/null @@ -1,10 +0,0 @@ -# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. -version: v1.22.1 -# ignores vulnerabilities until expiry date; change duration by modifying expiry date -ignore: - SNYK-JS-ANSIREGEX-1583908: - - '*': - reason: This only exposes the user's machine or build worker to a ReDos attack, which we don't consider an issue - expires: 2022-03-06T17:18:55.019Z - created: 2021-09-06T17:18:55.027Z -patch: {} diff --git a/packages/test-utils-core/CHANGELOG.md b/packages/test-utils-core/CHANGELOG.md deleted file mode 100644 index 35bdf1b2c6..0000000000 --- a/packages/test-utils-core/CHANGELOG.md +++ /dev/null @@ -1,22 +0,0 @@ -# @backstage/test-utils-core - -## 0.1.4 - -### Patch Changes - -- bb12aae352: Migrates all utility methods from `test-utils-core` into `test-utils` and delete exports from the old package. - This should have no impact since this package is considered internal and have no usages outside core packages. - - Notable changes are that the testing tool `msw.setupDefaultHandlers()` have been deprecated in favour of `setupRequestMockHandlers()`. - -## 0.1.3 - -### Patch Changes - -- ca0559444c: Avoid usage of `.to*Case()`, preferring `.toLocale*Case('en-US')` instead. - -## 0.1.2 - -### Patch Changes - -- 56c773909: Switched `@types/react` dependency to request `*` rather than a specific version. diff --git a/packages/test-utils-core/README.md b/packages/test-utils-core/README.md deleted file mode 100644 index 63b6ece0a6..0000000000 --- a/packages/test-utils-core/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# @backstage/test-utils-core - -This package provides utilities for testing the Backstage core packages. - -## Installation - -This package should not be used directly, use `@backstage/test-utils` instead. All exports from this -package are re-exported by `@backstage/test-utils`. - -The reason this package exists is to allow the Backstage core packages to use the testing utils exposed in this package. Since `@backstage/test-utils` needs to depend on `@backstage/core`, core is not able to use those test-utils. We put any test-utils that don't need to depend on other Backstage packages in this package, in order for them to be usable by any other `@backstage` packages. - -## Documentation - -- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/test-utils-core/api-report.md b/packages/test-utils-core/api-report.md deleted file mode 100644 index f21c749727..0000000000 --- a/packages/test-utils-core/api-report.md +++ /dev/null @@ -1,7 +0,0 @@ -## API Report File for "@backstage/test-utils-core" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts -// (No @packageDocumentation comment for this package) -``` diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json deleted file mode 100644 index ebe6decddb..0000000000 --- a/packages/test-utils-core/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@backstage/test-utils-core", - "description": "Utilities to test Backstage core", - "version": "0.1.4", - "private": false, - "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": "packages/test-utils-core" - }, - "keywords": [ - "backstage" - ], - "license": "Apache-2.0", - "main": "src/index.ts", - "types": "src/index.ts", - "scripts": { - "build": "backstage-cli build --outputs types,esm", - "lint": "backstage-cli lint", - "test": "backstage-cli test --passWithNoTests", - "prepack": "backstage-cli prepack", - "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" - }, - "dependencies": {}, - "devDependencies": { - "@types/jest": "^26.0.7", - "@types/node": "^14.14.32" - }, - "files": [ - "dist" - ] -} diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index 6fcacc30fa..fd8ce852db 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,82 @@ # @backstage/test-utils +## 0.1.23 + +### Patch Changes + +- 000190de69: The `ApiRegistry` from `@backstage/core-app-api` class has been deprecated and will be removed in a future release. To replace it, we have introduced two new helpers that are exported from `@backstage/test-utils`, namely `TestApiProvider` and `TestApiRegistry`. + + These two new helpers are more tailored for writing tests and development setups, as they allow for partial implementations of each of the APIs. + + When migrating existing code it is typically best to prefer usage of `TestApiProvider` when possible, so for example the following code: + + ```tsx + render( + + {...} + + ) + ``` + + Would be migrated to this: + + ```tsx + render( + + {...} + + ) + ``` + + In cases where the `ApiProvider` is used in a more standalone way, for example to reuse a set of APIs across multiple tests, the `TestApiRegistry` can be used instead. Note that the `TestApiRegistry` only has a single static factory method, `.from()`, and it is slightly different from the existing `.from()` method on `ApiRegistry` in that it doesn't require the API pairs to be wrapped in an outer array. + + Usage that looks like this: + + ```ts + const apis = ApiRegistry.with( + identityApiRef, + mockIdentityApi as unknown as IdentityApi, + ).with(configApiRef, new ConfigReader({})); + ``` + + OR like this: + + ```ts + const apis = ApiRegistry.from([ + [identityApiRef, mockIdentityApi as unknown as IdentityApi], + [configApiRef, new ConfigReader({})], + ]); + ``` + + Would be migrated to this: + + ```ts + const apis = TestApiRegistry.from( + [identityApiRef, mockIdentityApi], + [configApiRef, new ConfigReader({})], + ); + ``` + + If your app is still using the `ApiRegistry` to construct the `apis` for `createApp`, we recommend that you move over to use the new method of supplying API factories instead, using `createApiFactory`. + +- Updated dependencies + - @backstage/core-app-api@0.1.23 + - @backstage/core-plugin-api@0.2.1 + +## 0.1.22 + +### Patch Changes + +- 0b1de52732: Migrated to using new `ErrorApiError` and `ErrorApiErrorContext` names. +- 2dd2a7b2cc: Migrated to using `createSpecializedApp`. +- Updated dependencies + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 + ## 0.1.21 ### Patch Changes diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index 62a8d9f601..83c209122d 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -5,6 +5,8 @@ ```ts import { AnalyticsApi } from '@backstage/core-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { ApiHolder } from '@backstage/core-plugin-api'; +import { ApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ErrorApi } from '@backstage/core-plugin-api'; import { ErrorApiError } from '@backstage/core-plugin-api'; @@ -175,6 +177,26 @@ export function setupRequestMockHandlers(worker: { // @public export type SyncLogCollector = () => void; +// @public +export const TestApiProvider: ({ + apis, + children, +}: TestApiProviderProps) => JSX.Element; + +// @public +export type TestApiProviderProps = { + apis: readonly [...TestApiProviderPropsApiPairs]; + children: ReactNode; +}; + +// @public +export class TestApiRegistry implements ApiHolder { + static from( + ...apis: readonly [...TestApiProviderPropsApiPairs] + ): TestApiRegistry; + get(api: ApiRef): T | undefined; +} + // @public export type TestAppOptions = { routeEntries?: string[]; diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 6f5f78de8c..87c5b346f6 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.21", + "version": "0.1.23", "private": false, "publishConfig": { "access": "public", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-app-api": "^0.1.20", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-app-api": "^0.1.24", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.11.2", @@ -46,7 +46,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.2", + "@backstage/cli": "^0.10.0", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/test-utils/src/testUtils/TestApiProvider.test.tsx b/packages/test-utils/src/testUtils/TestApiProvider.test.tsx new file mode 100644 index 0000000000..1b981b15ec --- /dev/null +++ b/packages/test-utils/src/testUtils/TestApiProvider.test.tsx @@ -0,0 +1,135 @@ +/* + * 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 { createApiRef, useApiHolder } from '@backstage/core-plugin-api'; +import { TestApiProvider, TestApiRegistry } from './TestApiProvider'; +import { render, screen } from '@testing-library/react'; + +const xApiRef = createApiRef<{ a: string; b: number }>({ + id: 'x', +}); +const yApiRef = createApiRef({ + id: 'y', +}); + +function Verifier() { + const holder = useApiHolder(); + const x = holder.get(xApiRef); + const y = holder.get(yApiRef); + + return ( +
+ {x ? ( + + x={x.a},{x.b} + + ) : ( + no x + )} + {y ? y={y} : no y} +
+ ); +} + +describe('TestApiProvider', () => { + it('should provide APIs', () => { + render( + + + , + ); + expect(screen.getByText('x=a,3')).toBeInTheDocument(); + expect(screen.getByText('y=y')).toBeInTheDocument(); + }); + + it('should provide partial APIs', () => { + render( + + + , + ); + expect(screen.getByText('x=a,')).toBeInTheDocument(); + expect(screen.getByText('no y')).toBeInTheDocument(); + }); + + it('should require partial implementations to still match types', () => { + render( + // @ts-expect-error + + + , + ); + expect(screen.getByText('x=3,')).toBeInTheDocument(); + expect(screen.getByText('no y')).toBeInTheDocument(); + }); + + it('should allow empty APIs', () => { + render( + + + , + ); + expect(screen.getByText('no x')).toBeInTheDocument(); + expect(screen.getByText('no y')).toBeInTheDocument(); + }); +}); + +describe('TestApiRegistry', () => { + it('should be created with APIs', () => { + const x = { a: 'a', b: 3 }; + const y = 'y'; + const registry = TestApiRegistry.from([xApiRef, x], [yApiRef, y]); + + expect(registry.get(xApiRef)).toBe(x); + expect(registry.get(yApiRef)).toBe(y); + }); + + it('should allow partial implementations', () => { + const x = { a: 'a' }; + const registry = TestApiRegistry.from([xApiRef, x]); + + expect(registry.get(xApiRef)).toBe(x); + expect(registry.get(yApiRef)).toBeUndefined(); + }); + + it('should require partial implementations to match types', () => { + const x = { a: 2 }; + // @ts-expect-error + const registry = TestApiRegistry.from([xApiRef, x]); + + expect(registry.get(xApiRef)).toBe(x); + expect(registry.get(yApiRef)).toBeUndefined(); + }); + + it('should prefer last duplicate API that was provided', () => { + const x1 = { a: 'a' }; + const x2 = { a: 's' }; + const x3 = { a: 'd' }; + const registry = TestApiRegistry.from( + [xApiRef, x1], + [xApiRef, x2], + [xApiRef, x3], + ); + + expect(registry.get(xApiRef)).toBe(x3); + }); +}); diff --git a/packages/test-utils/src/testUtils/TestApiProvider.tsx b/packages/test-utils/src/testUtils/TestApiProvider.tsx new file mode 100644 index 0000000000..b1499b0cde --- /dev/null +++ b/packages/test-utils/src/testUtils/TestApiProvider.tsx @@ -0,0 +1,130 @@ +/* + * 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, { ReactNode } from 'react'; +import { ApiProvider } from '@backstage/core-app-api'; +import { ApiRef, ApiHolder } from '@backstage/core-plugin-api'; + +/** @ignore */ +type TestApiProviderPropsApiPair = TApi extends infer TImpl + ? readonly [ApiRef, Partial] + : never; + +/** @ignore */ +type TestApiProviderPropsApiPairs = { + [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair; +}; + +/** + * Properties for the {@link TestApiProvider} component. + * + * @public + */ +export type TestApiProviderProps = { + apis: readonly [...TestApiProviderPropsApiPairs]; + children: ReactNode; +}; + +/** + * The `TestApiRegistry` is an {@link @backstage/core-plugin-api#ApiHolder} implementation + * that is particularly well suited for development and test environments such as + * unit tests, storybooks, and isolated plugin development setups. + * + * @public + */ +export class TestApiRegistry implements ApiHolder { + /** + * Creates a new {@link TestApiRegistry} with a list of API implementation pairs. + * + * Similar to the {@link TestApiProvider}, there is no need to provide a full + * implementation of each API, it's enough to implement the methods that are tested. + * + * @example + * ```ts + * const apis = TestApiRegistry.from( + * [configApiRef, new ConfigReader({})], + * [identityApiRef, { getUserId: () => 'tester' }], + * ); + * ``` + * + * @public + * @param apis - A list of pairs mapping an ApiRef to its respective implementation. + */ + static from( + ...apis: readonly [...TestApiProviderPropsApiPairs] + ) { + return new TestApiRegistry( + new Map(apis.map(([api, impl]) => [api.id, impl])), + ); + } + + private constructor(private readonly apis: Map) {} + + /** + * Returns an implementation of the API. + * + * @public + */ + get(api: ApiRef): T | undefined { + return this.apis.get(api.id) as T | undefined; + } +} + +/** + * The `TestApiProvider` is a Utility API context provider that is particularly + * well suited for development and test environments such as unit tests, storybooks, + * and isolated plugin development setups. + * + * It lets you provide any number of API implementations, without necessarily + * having to fully implement each of the APIs. + * + * A migration from `ApiRegistry` and `ApiProvider` might look like this, from: + * + * ```tsx + * renderInTestApp( + * + * {...} + * + * ) + * ``` + * + * To the following: + * + * ```tsx + * renderInTestApp( + * + * {...} + * + * ) + * ``` + * + * Note that the cast to `IdentityApi` is no longer needed as long as the mock API + * implements a subset of the `IdentityApi`. + * + * @public + **/ +export const TestApiProvider = ({ + apis, + children, +}: TestApiProviderProps) => { + return ( + + ); +}; diff --git a/packages/test-utils/src/testUtils/appWrappers.test.tsx b/packages/test-utils/src/testUtils/appWrappers.test.tsx index 25809912ec..39e5da3df2 100644 --- a/packages/test-utils/src/testUtils/appWrappers.test.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { createExternalRouteRef, createRouteRef, @@ -29,6 +28,7 @@ import React, { useEffect } from 'react'; import { Route, Routes } from 'react-router'; import { MockErrorApi } from './apis'; import { renderInTestApp, wrapInTestApp } from './appWrappers'; +import { TestApiProvider } from './TestApiProvider'; describe('wrapInTestApp', () => { it('should provide routing and warn about missing act()', async () => { @@ -111,9 +111,9 @@ describe('wrapInTestApp', () => { }; const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('foo')).toBeInTheDocument(); diff --git a/packages/test-utils/src/testUtils/index.tsx b/packages/test-utils/src/testUtils/index.tsx index 7d93d606cc..c778c5c837 100644 --- a/packages/test-utils/src/testUtils/index.tsx +++ b/packages/test-utils/src/testUtils/index.tsx @@ -22,3 +22,5 @@ export * from './msw'; export * from './Keyboard'; export * from './logCollector'; export * from './testingLibrary'; +export { TestApiProvider, TestApiRegistry } from './TestApiProvider'; +export type { TestApiProviderProps } from './TestApiProvider'; diff --git a/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index 1c8661d7cc..cfa6ee3633 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/theme +## 0.2.14 + +### Patch Changes + +- e34f174fc5: Added `` and `` to enable building better sidebars. You can check out the storybook for more inspiration and how to get started. + + Added two new theme props for styling the sidebar too, `navItem.hoverBackground` and `submenu.background`. + +- 59677fadb1: Improvements to API Reference documentation + ## 0.2.13 ### Patch Changes diff --git a/packages/theme/api-report.md b/packages/theme/api-report.md index 2dc6ab7b55..f650636710 100644 --- a/packages/theme/api-report.md +++ b/packages/theme/api-report.md @@ -41,6 +41,12 @@ export type BackstagePaletteAdditions = { indicator: string; color: string; selectedColor: string; + navItem?: { + hoverBackground: string; + }; + submenu?: { + background: string; + }; }; tabbar: { indicator: string; diff --git a/packages/theme/package.json b/packages/theme/package.json index d9e89c969f..08477dccd0 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.2.13", + "version": "0.2.14", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "@material-ui/core": "^4.12.2" }, "devDependencies": { - "@backstage/cli": "^0.8.2" + "@backstage/cli": "^0.10.0" }, "files": [ "dist" diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index 93fec977f9..41f827bf43 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -15,7 +15,7 @@ */ /** - * material-ui theme for use with Backstage. + * {@link https://mui.com | material-ui} theme for use with Backstage * * @packageDocumentation */ diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index 7e99ce8e8a..b048c70fec 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -76,6 +76,12 @@ export const lightTheme = createTheme({ indicator: '#9BF0E1', color: '#b5b5b5', selectedColor: '#FFF', + navItem: { + hoverBackground: '#404040', + }, + submenu: { + background: '#404040', + }, }, pinSidebarButton: { icon: '#181818', @@ -151,6 +157,12 @@ export const darkTheme = createTheme({ indicator: '#9BF0E1', color: '#b5b5b5', selectedColor: '#FFF', + navItem: { + hoverBackground: '#404040', + }, + submenu: { + background: '#404040', + }, }, pinSidebarButton: { icon: '#404040', diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index e1acba3a01..3d1a64c38f 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -53,6 +53,12 @@ export type BackstagePaletteAdditions = { indicator: string; color: string; selectedColor: string; + navItem?: { + hoverBackground: string; + }; + submenu?: { + background: string; + }; }; tabbar: { indicator: string; diff --git a/packages/types/package.json b/packages/types/package.json index 8632422a25..822b2d678e 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -31,7 +31,7 @@ }, "dependencies": {}, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/zen-observable": "^0.8.0", "zen-observable": "^0.8.15" }, diff --git a/packages/version-bridge/package.json b/packages/version-bridge/package.json index 7cc0b56a34..89baaea2f5 100644 --- a/packages/version-bridge/package.json +++ b/packages/version-bridge/package.json @@ -33,7 +33,7 @@ "react": "^16.12.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2" diff --git a/plugins/allure/CHANGELOG.md b/plugins/allure/CHANGELOG.md index 3de49b9b74..f769b6e133 100644 --- a/plugins/allure/CHANGELOG.md +++ b/plugins/allure/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-allure +## 0.1.7 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.6 ### Patch Changes diff --git a/plugins/allure/package.json b/plugins/allure/package.json index b157795eec..5c795e919f 100644 --- a/plugins/allure/package.json +++ b/plugins/allure/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-allure", "description": "A Backstage plugin that integrates with Allure", - "version": "0.1.6", + "version": "0.1.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,11 +22,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -36,10 +36,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/allure/src/plugin.ts b/plugins/allure/src/plugin.ts index f5ca7beeb3..a2eaa06a38 100644 --- a/plugins/allure/src/plugin.ts +++ b/plugins/allure/src/plugin.ts @@ -23,7 +23,7 @@ import { import { AllureApiClient, allureApiRef } from './api'; export const allureRouteRef = createRouteRef({ - title: 'allure-report', + id: 'allure', }); export const allurePlugin = createPlugin({ diff --git a/plugins/analytics-module-ga/CHANGELOG.md b/plugins/analytics-module-ga/CHANGELOG.md index a59bcb6351..acc5c235f9 100644 --- a/plugins/analytics-module-ga/CHANGELOG.md +++ b/plugins/analytics-module-ga/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-analytics-module-ga +## 0.1.3 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.2 ### Patch Changes diff --git a/plugins/analytics-module-ga/config.d.ts b/plugins/analytics-module-ga/config.d.ts index 4ccd66e1dd..ac534daae8 100644 --- a/plugins/analytics-module-ga/config.d.ts +++ b/plugins/analytics-module-ga/config.d.ts @@ -27,6 +27,13 @@ export interface Config { */ trackingId: string; + /** + * URL to Google Analytics analytics.js script + * Defaults to fetching from GA source (eg. https://www.google-analytics.com/analytics.js) + * @visibility frontend + */ + scriptSrc?: string; + /** * Whether or not to log analytics debug statements to the console. * Defaults to false. diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index 4970f50c04..e3a3c74111 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-analytics-module-ga", - "version": "0.1.2", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,9 +22,9 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -34,10 +34,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index 1e6216936c..23da7c8589 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -42,18 +42,25 @@ export class GoogleAnalytics implements AnalyticsApi { private constructor({ cdmConfig, trackingId, + scriptSrc, testMode, debug, }: { cdmConfig: CustomDimensionOrMetricConfig[]; trackingId: string; + scriptSrc?: string; testMode: boolean; debug: boolean; }) { this.cdmConfig = cdmConfig; // Initialize Google Analytics. - ReactGA.initialize(trackingId, { testMode, debug, titleCase: false }); + ReactGA.initialize(trackingId, { + testMode, + debug, + gaAddress: scriptSrc, + titleCase: false, + }); } /** @@ -62,6 +69,7 @@ export class GoogleAnalytics implements AnalyticsApi { static fromConfig(config: Config) { // Get all necessary configuration. const trackingId = config.getString('app.analytics.ga.trackingId'); + const scriptSrc = config.getOptionalString('app.analytics.ga.scriptSrc'); const debug = config.getOptionalBoolean('app.analytics.ga.debug') ?? false; const testMode = config.getOptionalBoolean('app.analytics.ga.testMode') ?? false; @@ -82,6 +90,7 @@ export class GoogleAnalytics implements AnalyticsApi { // Return an implementation instance. return new GoogleAnalytics({ trackingId, + scriptSrc, cdmConfig, testMode, debug, diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index 78f4b08bd7..74b7ae8ab9 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,36 @@ # @backstage/plugin-api-docs +## 0.6.16 + +### Patch Changes + +- 752a53d94e: Improve theme integration for OpenApi definition +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.6.15 + +### Patch Changes + +- c982fc12cb: Adjusted some styles in the OpenAPI definition, for elements which were barely readable in dark mode. +- Updated dependencies + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.6.14 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/plugin-catalog@0.7.3 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.6.13 ### Patch Changes diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md index d5ed198dc1..4919690568 100644 --- a/plugins/api-docs/README.md +++ b/plugins/api-docs/README.md @@ -160,6 +160,14 @@ by this plugin. Grab a copy of [oauth2-redirect.html](https://github.com/swagger-api/swagger-ui/blob/master/dist/oauth2-redirect.html) and put it in the `app/public/` directory in order to enable Swagger UI to complete this redirection. +This also may require you to adjust `Content Security Policy` header settings of your Backstage application, so that the script in `oauth2-redirect.html` can be executed. Since the script is static we can add the hash of it directly to our CSP policy, which we do by adding the following to the `csp` section of the app configuration: + +```yaml +script-src: + - "'self'" + - "'sha256-GeDavzSZ8O71Jggf/pQkKbt52dfZkrdNMQ3e+Ox+AkI='" # oauth2-redirect.html +``` + #### Configuring your OAuth2 Client You'll need to make sure your OAuth2 client has been registered in your OAuth2 Authentication Server (AS) diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 61ae26c776..aa4fde2043 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-api-docs", "description": "A Backstage plugin that helps represent API entities in the frontend", - "version": "0.6.13", + "version": "0.6.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,12 +31,12 @@ }, "dependencies": { "@asyncapi/react-component": "^0.23.0", - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog": "^0.7.2", - "@backstage/plugin-catalog-react": "^0.6.3", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog": "^0.7.3", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -53,10 +53,10 @@ "swagger-ui-react": "^4.0.0-rc.3" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx index 7b0ffac58e..32a6ec0f77 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx @@ -16,13 +16,12 @@ import { ApiEntity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget'; import { ApiDefinitionCard } from './ApiDefinitionCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const apiDocsConfig: jest.Mocked = { @@ -31,10 +30,10 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(apiDocsConfigRef, apiDocsConfig); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiTypeTitle.test.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiTypeTitle.test.tsx index 450e9e57ac..5476857a06 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiTypeTitle.test.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiTypeTitle.test.tsx @@ -15,11 +15,10 @@ */ import { ApiEntity } from '@backstage/catalog-model'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { ApiTypeTitle } from './ApiTypeTitle'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const apiDocsConfig: jest.Mocked = { @@ -28,10 +27,10 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(apiDocsConfigRef, apiDocsConfig); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index cf40e5160c..3629110d47 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -15,11 +15,7 @@ */ import { Entity, RELATION_MEMBER_OF } from '@backstage/catalog-model'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/core-app-api'; import { TableColumn, TableProps } from '@backstage/core-components'; import { ConfigApi, @@ -34,7 +30,11 @@ import { entityRouteRef, starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; -import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { + MockStorageApi, + TestApiProvider, + wrapInTestApp, +} from '@backstage/test-utils'; import DashboardIcon from '@material-ui/icons/Dashboard'; import { render } from '@testing-library/react'; import React from 'react'; @@ -88,8 +88,8 @@ describe('ApiCatalogPage', () => { const renderWrapped = (children: React.ReactNode) => render( wrapInTestApp( - { new DefaultStarredEntitiesApi({ storageApi }), ], [apiDocsConfigRef, apiDocsConfig], - ])} + ]} > {children} - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index ac09b717f7..5e7b22524a 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -21,12 +21,11 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { ConsumedApisCard } from './ConsumedApisCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const apiDocsConfig: jest.Mocked = { @@ -43,13 +42,15 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( - apiDocsConfigRef, - apiDocsConfig, - ); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx index ac833251e3..4102bebf0e 100644 --- a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx @@ -21,12 +21,11 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { HasApisCard } from './HasApisCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const apiDocsConfig: jest.Mocked = { @@ -43,13 +42,15 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( - apiDocsConfigRef, - apiDocsConfig, - ); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index f2719d0f4b..b29075aee9 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -21,12 +21,11 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { ProvidedApisCard } from './ProvidedApisCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const apiDocsConfig: jest.Mocked = { @@ -43,13 +42,15 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( - apiDocsConfigRef, - apiDocsConfig, - ); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index 274962c346..06022eec69 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -21,11 +21,10 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ConsumingComponentsCard } from './ConsumingComponentsCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const catalogApi: jest.Mocked = { @@ -39,10 +38,10 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index 879944749a..275338d2f9 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -21,11 +21,10 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ProvidingComponentsCard } from './ProvidingComponentsCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const catalogApi: jest.Mocked = { @@ -39,10 +38,10 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.tsx b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.tsx index 4a74b238f7..601352f21a 100644 --- a/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.tsx +++ b/plugins/api-docs/src/components/OpenApiDefinitionWidget/OpenApiDefinition.tsx @@ -22,18 +22,9 @@ import 'swagger-ui-react/swagger-ui.css'; const useStyles = makeStyles(theme => ({ root: { '& .swagger-ui': { - fontFamily: 'inherit', + fontFamily: theme.typography.fontFamily, color: theme.palette.text.primary, - [`& .info h1, - .info h2, - .info h3, - .info h4, - .info h5, - .info h6`]: { - fontFamily: 'inherit', - color: theme.palette.text.primary, - }, [`& .scheme-container`]: { backgroundColor: theme.palette.background.default, }, @@ -41,7 +32,7 @@ const useStyles = makeStyles(theme => ({ .opblock-tag small, table thead tr td, table thead tr th`]: { - fontFamily: 'inherit', + fontFamily: theme.typography.fontFamily, color: theme.palette.text.primary, borderColor: theme.palette.divider, }, @@ -49,34 +40,30 @@ const useStyles = makeStyles(theme => ({ section.models.is-open h4`]: { borderColor: theme.palette.divider, }, - [`& .opblock .opblock-summary-description, - .parameter__type, - table.headers td, - .model-title, - .model .property.primitive, - section h3`]: { - fontFamily: 'inherit', - color: theme.palette.text.secondary, + [`& .model-title, + .model .renderedMarkdown, + .model .description`]: { + fontFamily: theme.typography.fontFamily, + fontWeight: theme.typography.fontWeightRegular, }, - [`& .opblock .opblock-summary-operation-id, - .opblock .opblock-summary-path, - .opblock .opblock-summary-path__deprecated, - .opblock .opblock-section-header h4, + [`& h1, h2, h3, h4, h5, h6, + .errors h4, .error h4, .opblock h4, section.models h4, + .response-control-media-type__accept-message, + .opblock-summary-description, + .opblock-summary-operation-id, + .opblock-summary-path, + .opblock-summary-path__deprecated, + .opblock-external-docs-wrapper, + .opblock-section-header .btn, + .opblock-section-header>label, + .scheme-container .schemes>label,a.nostyle, .parameter__name, .response-col_status, .response-col_links, - .responses-inner h4, - .responses-inner h5, - .opblock-section-header .btn, - .tab li, - .info li, - .info p, - .info table, - section.models h4, + .error .btn, .info .title, - table.model tr.description, - .property-row`]: { - fontFamily: 'inherit', + .info .base-url`]: { + fontFamily: theme.typography.fontFamily, color: theme.palette.text.primary, }, [`& .opblock .opblock-section-header, @@ -88,22 +75,57 @@ const useStyles = makeStyles(theme => ({ .parameter__in`]: { color: theme.palette.text.disabled, }, - [`& .opblock-description-wrapper p, - .opblock-external-docs-wrapper p, - .opblock-title_normal p, - .response-control-media-type__accept-message, - .opblock .opblock-section-header>label, - .scheme-container .schemes>label, - .info .base-url, - .model`]: { - color: theme.palette.text.hint, + [`& table.model, + .parameter__type, + .model.model-title, + .model-title, + .model span, + .model .brace-open, + .model .brace-close, + .model .property.primitive, + .model .renderedMarkdown, + .model .description, + .errors small`]: { + color: theme.palette.text.secondary, }, [`& .parameter__name.required:after`]: { color: theme.palette.warning.dark, }, - [`& .prop-type`]: { + [`& table.model, + table.model .model, + .opblock-external-docs-wrapper`]: { + fontSize: theme.typography.fontSize, + }, + [`& table.headers td`]: { + color: theme.palette.text.primary, + fontWeight: theme.typography.fontWeightRegular, + }, + [`& .model-hint`]: { + color: theme.palette.text.hint, + backgroundColor: theme.palette.background.paper, + }, + [`& .opblock-summary-method, + .info a`]: { + fontFamily: theme.typography.fontFamily, + }, + [`& .info, .opblock, .tab`]: { + [`& li, p`]: { + fontFamily: theme.typography.fontFamily, + color: theme.palette.text.primary, + }, + }, + [`& a`]: { color: theme.palette.primary.main, }, + [`& .renderedMarkdown code`]: { + color: theme.palette.secondary.light, + }, + [`& .property-row td:first-child`]: { + color: theme.palette.text.primary, + }, + [`& span.prop-type`]: { + color: theme.palette.success.light, + }, }, }, })); diff --git a/plugins/api-docs/src/routes.ts b/plugins/api-docs/src/routes.ts index ab976dbf18..b7c152889e 100644 --- a/plugins/api-docs/src/routes.ts +++ b/plugins/api-docs/src/routes.ts @@ -19,12 +19,8 @@ import { createRouteRef, } from '@backstage/core-plugin-api'; -const NoIcon = () => null; - export const rootRoute = createRouteRef({ - icon: NoIcon, - path: '/api-docs', - title: 'APIs', + id: 'api-docs', }); export const createComponentRouteRef = createExternalRouteRef({ diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index 0658b10ebb..a58b028049 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-app-backend +## 0.3.19 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@0.8.0 + - @backstage/backend-common@0.9.10 + ## 0.3.18 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index d989457c79..927eae7022 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-app-backend", "description": "A Backstage backend plugin that serves the Backstage frontend app", - "version": "0.3.18", + "version": "0.3.19", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.8", - "@backstage/config-loader": "^0.7.1", + "@backstage/backend-common": "^0.9.12", + "@backstage/config-loader": "^0.8.1", "@backstage/config": "^0.1.11", "@backstage/types": "^0.1.1", "@types/express": "^4.17.6", @@ -42,7 +42,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.10.0", "@backstage/types": "^0.1.1", "@types/supertest": "^2.0.8", "msw": "^0.35.0", diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index a11f561986..d7492ba5b4 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,52 @@ # @backstage/plugin-auth-backend +## 0.4.10 + +### Patch Changes + +- 4bf4111902: Migrated the SAML provider to implement the `authHandler` and `signIn.resolver` options. +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- 36fa32216f: Added signIn and authHandler resolver for oidc provider +- 7071dce02d: Expose catalog lib in plugin-auth-backend, i.e `CatalogIdentityClient` class is exposed now. +- 1b69ed44f2: Added custom OAuth2.0 authorization header for generic oauth2 provider. +- Updated dependencies + - @backstage/backend-common@0.9.12 + +## 0.4.9 + +### Patch Changes + +- 9312572360: Switched to using the standardized JSON error responses for all provider endpoints. +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/backend-common@0.9.11 + - @backstage/test-utils@0.1.23 + +## 0.4.8 + +### Patch Changes + +- 892c1d9202: Update OAuthAdapter to create identity.token from identity.idToken if it does not exist, and prevent overwrites to identity.toke. Update login page commonProvider to prefer .token over .idToken +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + - @backstage/test-utils@0.1.22 + ## 0.4.7 ### Patch Changes diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index 1fea3346a7..fe2cd05c22 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -34,7 +34,7 @@ Follow this link, [Create new OAuth App](https://github.com/settings/application 1. Set Application Name to `backstage-dev` or something along those lines. 1. You can set the Homepage URL to whatever you want to. 1. The Authorization Callback URL should match the redirect URI set in Backstage. - 1. Set this to `http://localhost:7000/api/auth/github` for local development. + 1. Set this to `http://localhost:7007/api/auth/github` for local development. 1. Set this to `http://{APP_FQDN}:{APP_BACKEND_PORT}/api/auth/github` for non-local deployments. ```bash @@ -58,7 +58,7 @@ Follow this link, [Add new application](https://gitlab.com/-/profile/application 1. Set Application Name to `backstage-dev` or something along those lines. 1. The Authorization Callback URL should match the redirect URI set in Backstage. - 1. Set this to `http://localhost:7000/api/auth/gitlab/handler/frame` for local development. + 1. Set this to `http://localhost:7007/api/auth/gitlab/handler/frame` for local development. 1. Set this to `http://{APP_FQDN}:{APP_BACKEND_PORT}/api/auth/gitlab/handler/frame` for non-local deployments. 1. Select the following scopes from the list: - [x] `read_user` Grants read-only access to the authenticated user's profile through the /user API endpoint, which includes username, public email, and full name. Also grants access to read-only API endpoints under /users. @@ -91,9 +91,9 @@ export AUTH_GITLAB_CLIENT_SECRET=x Add a new Okta application using the following URI conventions: -Login redirect URI's: `http://localhost:7000/api/auth/okta/handler/frame` -Logout redirect URI's: `http://localhost:7000/api/auth/okta/logout` -Initiate login URI's: `http://localhost:7000/api/auth/okta/start` +Login redirect URI's: `http://localhost:7007/api/auth/okta/handler/frame` +Logout redirect URI's: `http://localhost:7007/api/auth/okta/logout` +Initiate login URI's: `http://localhost:7007/api/auth/okta/start` Then configure the following environment variables to be used in the `app-config.yaml` file: @@ -122,7 +122,7 @@ Click [here](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMe - Give the app a name. e.g. `backstage-dev` - Select `Accounts in this organizational directory only` under supported account types. - Enter the callback URL for your backstage backend instance: - - For local development, this is likely `http://localhost:7000/api/auth/microsoft/handler/frame` + - For local development, this is likely `http://localhost:7007/api/auth/microsoft/handler/frame` - For non-local deployments, this will be `https://{APP_FQDN}:{APP_BACKEND_PORT}/auth/microsoft/handler/frame` - Click `Register`. diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 35c93e9537..46d8b03cbb 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -14,7 +14,9 @@ import { Logger as Logger_2 } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Profile } from 'passport'; +import { TokenSet } from 'openid-client'; import { UserEntity } from '@backstage/catalog-model'; +import { UserinfoResponse } from 'openid-client'; // Warning: (ae-missing-release-tag) "AtlassianAuthProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -175,6 +177,20 @@ export const bitbucketUserIdSignInResolver: SignInResolver // @public (undocumented) export const bitbucketUsernameSignInResolver: SignInResolver; +// Warning: (ae-missing-release-tag) "CatalogIdentityClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class CatalogIdentityClient { + constructor(options: { catalogApi: CatalogApi; tokenIssuer: TokenIssuer }); + // Warning: (ae-forgotten-export) The symbol "UserQuery" needs to be exported by the entry point index.d.ts + findUser(query: UserQuery): Promise; + // Warning: (ae-forgotten-export) The symbol "MemberClaimQuery" needs to be exported by the entry point index.d.ts + resolveCatalogMembership({ + entityRefs, + logger, + }: MemberClaimQuery): Promise; +} + // Warning: (ae-missing-release-tag) "createAtlassianProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -231,6 +247,14 @@ export const createOAuth2Provider: ( options?: OAuth2ProviderOptions | undefined, ) => AuthProviderFactory; +// Warning: (ae-forgotten-export) The symbol "OidcProviderOptions" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "createOidcProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const createOidcProvider: ( + options?: OidcProviderOptions | undefined, +) => AuthProviderFactory; + // Warning: (ae-missing-release-tag) "createOktaProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -254,6 +278,11 @@ export function createRouter({ providerFactories, }: RouterOptions): Promise; +// @public (undocumented) +export const createSamlProvider: ( + options?: SamlProviderOptions | undefined, +) => AuthProviderFactory; + // Warning: (ae-missing-release-tag) "factories" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -271,6 +300,12 @@ export const encodeState: (state: OAuthState) => string; // @public (undocumented) export const ensuresXRequestedWith: (req: express.Request) => boolean; +// Warning: (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "getEntityClaims" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function getEntityClaims(entity: UserEntity): TokenParams['claims']; + // Warning: (ae-missing-release-tag) "GithubOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -548,6 +583,19 @@ export interface RouterOptions { providerFactories?: ProviderFactories; } +// @public (undocumented) +export type SamlAuthResult = { + fullProfile: any; +}; + +// @public (undocumented) +export type SamlProviderOptions = { + authHandler?: AuthHandler; + signIn?: { + resolver?: SignInResolver; + }; +}; + // Warning: (ae-missing-release-tag) "TokenIssuer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -578,7 +626,6 @@ export type WebMessageResponse = // Warnings were encountered during analysis: // -// src/identity/types.d.ts:25:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts // src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts // src/providers/atlassian/provider.d.ts:37:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts // src/providers/atlassian/provider.d.ts:42:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index e9ec29970b..6f95c9850a 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-auth-backend", "description": "A Backstage backend plugin that handles authentication", - "version": "0.4.7", + "version": "0.4.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,19 +30,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.9", - "@backstage/catalog-client": "^0.5.1", - "@backstage/catalog-model": "^0.9.6", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/errors": "^0.1.4", - "@backstage/test-utils": "^0.1.21", + "@backstage/errors": "^0.1.5", + "@backstage/test-utils": "^0.1.23", "@google-cloud/firestore": "^4.15.1", "@types/express": "^4.17.6", "@types/passport": "^1.0.3", "compression": "^1.7.4", "cookie-parser": "^1.4.5", "cors": "^2.8.5", - "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-session": "^1.17.1", @@ -56,6 +55,7 @@ "luxon": "^2.0.2", "minimatch": "^3.0.3", "morgan": "^1.10.0", + "node-fetch": "^2.6.1", "node-cache": "^5.1.2", "openid-client": "^4.2.1", "passport": "^0.4.1", @@ -73,7 +73,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.2", + "@backstage/cli": "^0.10.0", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/auth-backend/scripts/start-saml-idp.sh b/plugins/auth-backend/scripts/start-saml-idp.sh index b312a4b85e..e12b725191 100755 --- a/plugins/auth-backend/scripts/start-saml-idp.sh +++ b/plugins/auth-backend/scripts/start-saml-idp.sh @@ -18,4 +18,4 @@ fi echo "Downloading and starting SAML-IdP" export NPM_CONFIG_REGISTRY=https://registry.npmjs.org -exec npx saml-idp --acsUrl "http://localhost:7000/api/auth/saml/handler/frame" --audience "http://localhost:7000" --port 7001 +exec npx saml-idp --acsUrl "http://localhost:7007/api/auth/saml/handler/frame" --audience "http://localhost:7007" --port 7001 diff --git a/plugins/auth-backend/src/identity/IdentityClient.ts b/plugins/auth-backend/src/identity/IdentityClient.ts index 2af30e4b6a..d60829bf59 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.ts +++ b/plugins/auth-backend/src/identity/IdentityClient.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import fetch from 'cross-fetch'; +import fetch from 'node-fetch'; import { JWK, JWT, JWKS, JSONWebKey } from 'jose'; import { BackstageIdentity } from '../providers'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index 432da00142..894f8c1e55 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -31,3 +31,5 @@ export * from './lib/flow'; // OAuth wrapper over a passport or a custom `startegy`. export * from './lib/oauth'; + +export * from './lib/catalog'; diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index dfb3a0a79a..fa61d69d80 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -176,7 +176,7 @@ describe('OAuthAdapter', () => { const mockResponse = { cookie: jest.fn().mockReturnThis(), - send: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), status: jest.fn().mockReturnThis(), } as unknown as express.Response; @@ -187,6 +187,7 @@ describe('OAuthAdapter', () => { '', expect.objectContaining({ path: '/auth/test-provider' }), ); + expect(mockResponse.end).toHaveBeenCalledTimes(1); }); it('gets new access-token when refreshing', async () => { @@ -230,21 +231,14 @@ describe('OAuthAdapter', () => { const mockRequest = { header: () => 'XMLHttpRequest', - cookies: { - 'test-provider-refresh-token': 'token', - }, - query: {}, } as unknown as express.Request; - const mockResponse = { - send: jest.fn().mockReturnThis(), - status: jest.fn().mockReturnThis(), - } as unknown as express.Response; + const mockResponse = {} as unknown as express.Response; - await oauthProvider.refresh(mockRequest, mockResponse); - expect(mockResponse.send).toHaveBeenCalledTimes(1); - expect(mockResponse.send).toHaveBeenCalledWith( - 'Refresh token not supported for provider: test-provider', + await expect( + oauthProvider.refresh(mockRequest, mockResponse), + ).rejects.toThrow( + 'Refresh token is not supported for provider test-provider', ); }); }); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index 24b9a0f210..e1a6fdf2f9 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -22,7 +22,12 @@ import { BackstageIdentity, AuthProviderConfig, } from '../../providers/types'; -import { InputError, isError, NotAllowedError } from '@backstage/errors'; +import { + AuthenticationError, + InputError, + isError, + NotAllowedError, +} from '@backstage/errors'; import { TokenIssuer } from '../../identity/types'; import { readState, verifyNonce } from './helpers'; import { postMessageResponse, ensuresXRequestedWith } from '../flow'; @@ -166,29 +171,24 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { async logout(req: express.Request, res: express.Response): Promise { if (!ensuresXRequestedWith(req)) { - res.status(401).send('Invalid X-Requested-With header'); - return; + throw new AuthenticationError('Invalid X-Requested-With header'); } // remove refresh token cookie if it is set this.removeRefreshTokenCookie(res); - res.status(200).send('logout!'); + res.status(200).end(); } async refresh(req: express.Request, res: express.Response): Promise { if (!ensuresXRequestedWith(req)) { - res.status(401).send('Invalid X-Requested-With header'); - return; + throw new AuthenticationError('Invalid X-Requested-With header'); } if (!this.handlers.refresh || this.options.disableRefresh) { - res - .status(400) - .send( - `Refresh token not supported for provider: ${this.options.providerId}`, - ); - return; + throw new InputError( + `Refresh token is not supported for provider ${this.options.providerId}`, + ); } try { @@ -197,7 +197,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { // throw error if refresh token is missing in the request if (!refreshToken) { - throw new Error('Missing session cookie'); + throw new InputError('Missing session cookie'); } const scope = req.query.scope?.toString() ?? ''; @@ -220,7 +220,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { res.status(200).json(response); } catch (error) { - res.status(401).send(String(error)); + throw new AuthenticationError('Refresh failed', error); } } diff --git a/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts index a2f427e741..95a0547a5e 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts @@ -16,7 +16,7 @@ import express from 'express'; import { Config } from '@backstage/config'; -import { InputError } from '@backstage/errors'; +import { InputError, NotFoundError } from '@backstage/errors'; import { readState } from './helpers'; import { AuthProviderRouteHandlers } from '../../providers/types'; @@ -42,26 +42,26 @@ export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers { ) {} async start(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.start(req, res); + const provider = this.getProviderForEnv(req); + await provider.start(req, res); } async frameHandler( req: express.Request, res: express.Response, ): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.frameHandler(req, res); + const provider = this.getProviderForEnv(req); + await provider.frameHandler(req, res); } async refresh(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.refresh?.(req, res); + const provider = this.getProviderForEnv(req); + await provider.refresh?.(req, res); } async logout(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req, res); - await provider?.logout?.(req, res); + const provider = this.getProviderForEnv(req); + await provider.logout?.(req, res); } private getRequestFromEnv(req: express.Request): string | undefined { @@ -77,26 +77,20 @@ export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers { return env; } - private getProviderForEnv( - req: express.Request, - res: express.Response, - ): AuthProviderRouteHandlers | undefined { + private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers { const env: string | undefined = this.getRequestFromEnv(req); if (!env) { throw new InputError(`Must specify 'env' query to select environment`); } - if (!this.handlers.has(env)) { - res.status(404).send( - `Missing configuration. -
-
- For this flow to work you need to supply a valid configuration for the "${env}" environment of provider.`, + const handler = this.handlers.get(env); + if (!handler) { + throw new NotFoundError( + `No configuration available for the '${env}' environment of this provider.`, ); - return undefined; } - return this.handlers.get(env); + return handler; } } diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts index 2f5ffa1833..3f72004d48 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -50,7 +50,7 @@ const mockClaims = { }; jest.mock('jose'); -jest.mock('cross-fetch', () => ({ +jest.mock('node-fetch', () => ({ __esModule: true, default: async () => { return { diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts index ac5db5e69f..b0b9070d50 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -21,7 +21,7 @@ import { SignInResolver, } from '../types'; import express from 'express'; -import fetch from 'cross-fetch'; +import fetch from 'node-fetch'; import * as crypto from 'crypto'; import { KeyObject } from 'crypto'; import { Logger } from 'winston'; diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 4a9221ce21..88c6ecb6fe 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -19,10 +19,12 @@ export * from './gitlab'; export * from './google'; export * from './microsoft'; export * from './oauth2'; +export * from './oidc'; export * from './okta'; export * from './bitbucket'; export * from './atlassian'; export * from './aws-alb'; +export * from './saml'; export { factories as defaultAuthProviderFactories } from './factories'; diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts index cf70580664..1160ad19c9 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -85,6 +85,12 @@ export class OAuth2AuthProvider implements OAuthHandlers { tokenURL: options.tokenUrl, passReqToCallback: false as true, scope: options.scope, + customHeaders: { + Authorization: `Basic ${this.encodeClientCredentials( + options.clientId, + options.clientSecret, + )}`, + }, }, ( accessToken: any, @@ -187,6 +193,10 @@ export class OAuth2AuthProvider implements OAuthHandlers { return response; } + + encodeClientCredentials(clientID: string, clientSecret: string): string { + return Buffer.from(`${clientID}:${clientSecret}`).toString('base64'); + } } export const oAuth2DefaultSignInResolver: SignInResolver = async ( diff --git a/plugins/auth-backend/src/providers/oidc/index.ts b/plugins/auth-backend/src/providers/oidc/index.ts index 8c755cd501..20d014a783 100644 --- a/plugins/auth-backend/src/providers/oidc/index.ts +++ b/plugins/auth-backend/src/providers/oidc/index.ts @@ -15,4 +15,3 @@ */ export { createOidcProvider } from './provider'; -export type { OidcProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts index ac5035e9a5..da96ae1a25 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.test.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.test.ts @@ -24,7 +24,8 @@ import { setupServer } from 'msw/node'; import { ClientMetadata, IssuerMetadata } from 'openid-client'; import { OAuthAdapter } from '../../lib/oauth'; import { AuthProviderFactoryOptions } from '../types'; -import { createOidcProvider, OidcAuthProvider } from './provider'; +import { createOidcProvider, OidcAuthProvider, Options } from './provider'; +import { getVoidLogger } from '@backstage/backend-common'; const issuerMetadata = { issuer: 'https://oidc.test', @@ -42,7 +43,23 @@ const issuerMetadata = { request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], }; -const clientMetadata = { +const catalogIdentityClient = { + findUser: jest.fn(), +}; +const tokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), +}; + +const clientMetadata: Options = { + authHandler: async input => ({ + profile: { + displayName: input.userinfo.email, + }, + }), + catalogIdentityClient: catalogIdentityClient as unknown as any, + logger: getVoidLogger(), + tokenIssuer: tokenIssuer as unknown as any, callbackUrl: 'https://oidc.test/callback', clientId: 'testclientid', clientSecret: 'testclientsecret', @@ -160,7 +177,7 @@ describe('OidcAuthProvider', () => { ...clientMetadata, metadataUrl: 'https://oidc.test/.well-known/openid-configuration', }, - }); + } as any); const options = { globalConfig: { appUrl: 'https://oidc.test', diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index 092ffa7099..eba9c72e41 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -16,28 +16,36 @@ import express from 'express'; import { - Issuer, Client, + Issuer, Strategy as OidcStrategy, TokenSet, UserinfoResponse, } from 'openid-client'; import { - OAuthAdapter, - OAuthProviderOptions, - OAuthHandlers, - OAuthResponse, - OAuthEnvironmentHandler, - OAuthStartRequest, encodeState, + OAuthAdapter, + OAuthEnvironmentHandler, + OAuthHandlers, + OAuthProviderOptions, OAuthRefreshRequest, + OAuthResponse, + OAuthStartRequest, } from '../../lib/oauth'; import { executeFrameHandlerStrategy, executeRedirectStrategy, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + AuthHandler, + AuthProviderFactory, + RedirectInfo, + SignInResolver, +} from '../types'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { TokenIssuer } from '../../identity'; +import { Logger } from 'winston'; type PrivateInfo = { refreshToken?: string; @@ -58,6 +66,11 @@ export type Options = OAuthProviderOptions & { scope?: string; prompt?: string; tokenSignedResponseAlg?: string; + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }; export class OidcAuthProvider implements OAuthHandlers { @@ -65,16 +78,26 @@ export class OidcAuthProvider implements OAuthHandlers { private readonly scope?: string; private readonly prompt?: string; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; + constructor(options: Options) { this.implementation = this.setupStrategy(options); this.scope = options.scope; this.prompt = options.prompt; + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.tokenIssuer = options.tokenIssuer; + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; } async start(req: OAuthStartRequest): Promise { const { strategy } = await this.implementation; const options: Record = { - accessType: 'offline', scope: req.scope || this.scope || 'openid profile email', state: encodeState(req.state), }; @@ -97,19 +120,8 @@ export class OidcAuthProvider implements OAuthHandlers { result: { userinfo, tokenset }, privateInfo, } = strategyResponse; - const identityResponse = await this.populateIdentity({ - profile: { - displayName: userinfo.name, - email: userinfo.email, - picture: userinfo.picture, - }, - providerInfo: { - idToken: tokenset.id_token, - accessToken: tokenset.access_token || '', - scope: tokenset.scope || '', - expiresInSeconds: tokenset.expires_in, - }, - }); + + const identityResponse = await this.handleResult({ tokenset, userinfo }); return { response: identityResponse, refreshToken: privateInfo.refreshToken, @@ -123,22 +135,13 @@ export class OidcAuthProvider implements OAuthHandlers { throw new Error('Refresh failed'); } const profile = await client.userinfo(tokenset.access_token); - - return this.populateIdentity({ - providerInfo: { - accessToken: tokenset.access_token, - refreshToken: tokenset.refresh_token, - expiresInSeconds: tokenset.expires_in, - idToken: tokenset.id_token, - scope: tokenset.scope || '', - }, - profile, - }); + return this.handleResult({ tokenset, userinfo: profile }); } private async setupStrategy(options: Options): Promise { const issuer = await Issuer.discover(options.metadataUrl); const client = new issuer.Client({ + access_type: 'offline', // this option must be passed to provider to receive a refresh token client_id: options.clientId, client_secret: options.clientSecret, redirect_uris: [options.callbackUrl], @@ -177,26 +180,70 @@ export class OidcAuthProvider implements OAuthHandlers { // Use this function to grab the user profile info from the token // Then populate the profile with it - private async populateIdentity( - response: OAuthResponse, - ): Promise { - const { profile } = response; - - if (!profile.email) { - throw new Error('Profile does not contain an email'); + private async handleResult(result: AuthResult): Promise { + const { profile } = await this.authHandler(result); + const response: OAuthResponse = { + providerInfo: { + idToken: result.tokenset.id_token, + accessToken: result.tokenset.access_token!, + refreshToken: result.tokenset.refresh_token, + scope: result.tokenset.scope!, + expiresInSeconds: result.tokenset.expires_in, + }, + profile, + }; + if (this.signInResolver) { + response.backstageIdentity = await this.signInResolver( + { + result, + profile, + }, + { + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, + }, + ); } - const id = profile.email.split('@')[0]; - - return { ...response, backstageIdentity: { id } }; + return response; } } -export type OidcProviderOptions = {}; +export const oAuth2DefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Profile contained no email'); + } + const userId = profile.email.split('@')[0]; + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: userId, ent: [`user:default/${userId}`] }, + }); + return { id: userId, token }; +}; + +export type OidcProviderOptions = { + authHandler?: AuthHandler; + + signIn?: { + resolver?: SignInResolver; + }; +}; export const createOidcProvider = ( - _options?: OidcProviderOptions, + options?: OidcProviderOptions, ): AuthProviderFactory => { - return ({ providerId, globalConfig, config, tokenIssuer }) => + return ({ + providerId, + globalConfig, + config, + tokenIssuer, + catalogApi, + logger, + }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); @@ -207,6 +254,28 @@ export const createOidcProvider = ( ); const scope = envConfig.getOptionalString('scope'); const prompt = envConfig.getOptionalString('prompt'); + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ userinfo }) => ({ + profile: { + displayName: userinfo.name, + email: userinfo.email, + picture: userinfo.picture, + }, + }); + const signInResolverFn = + options?.signIn?.resolver ?? oAuth2DefaultSignInResolver; + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); const provider = new OidcAuthProvider({ clientId, @@ -216,6 +285,11 @@ export const createOidcProvider = ( metadataUrl, scope, prompt, + signInResolver, + authHandler, + logger, + tokenIssuer, + catalogIdentityClient, }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/saml/index.ts b/plugins/auth-backend/src/providers/saml/index.ts index fa5d9d9a82..0aea660941 100644 --- a/plugins/auth-backend/src/providers/saml/index.ts +++ b/plugins/auth-backend/src/providers/saml/index.ts @@ -15,4 +15,4 @@ */ export { createSamlProvider } from './provider'; -export type { SamlProviderOptions } from './provider'; +export type { SamlProviderOptions, SamlAuthResult } from './provider'; diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 8929a1dbf0..3ab00ce401 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -26,31 +26,52 @@ import { executeRedirectStrategy, PassportDoneCallback, } from '../../lib/passport'; -import { AuthProviderRouteHandlers, AuthProviderFactory } from '../types'; +import { + AuthProviderRouteHandlers, + AuthProviderFactory, + AuthHandler, + SignInResolver, + AuthResponse, +} from '../types'; import { postMessageResponse } from '../../lib/flow'; import { TokenIssuer } from '../../identity/types'; import { isError } from '@backstage/errors'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { Logger } from 'winston'; -type SamlInfo = { +/** @public */ +export type SamlAuthResult = { fullProfile: any; }; type Options = SamlConfig & { + signInResolver?: SignInResolver; + authHandler: AuthHandler; tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; appUrl: string; }; export class SamlAuthProvider implements AuthProviderRouteHandlers { private readonly strategy: SamlStrategy; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; private readonly appUrl: string; constructor(options: Options) { this.appUrl = options.appUrl; + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; this.tokenIssuer = options.tokenIssuer; + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; this.strategy = new SamlStrategy({ ...options }, (( fullProfile: SamlProfile, - done: PassportDoneCallback, + done: PassportDoneCallback, ) => { // TODO: There's plenty more validation and profile handling to do here, // this provider is currently only intended to validate the provider pattern @@ -71,27 +92,35 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { res: express.Response, ): Promise { try { - const { result } = await executeFrameHandlerStrategy( + const { result } = await executeFrameHandlerStrategy( req, this.strategy, ); - const id = result.fullProfile.nameID; + const { profile } = await this.authHandler(result); - const idToken = await this.tokenIssuer.issueToken({ - claims: { sub: id }, - }); + const response: AuthResponse<{}> = { + profile, + providerInfo: {}, + }; + + if (this.signInResolver) { + response.backstageIdentity = await this.signInResolver( + { + result, + profile, + }, + { + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, + }, + ); + } return postMessageResponse(res, this.appUrl, { type: 'authorization_response', - response: { - profile: { - email: result.fullProfile.email, - displayName: result.fullProfile.displayName, - }, - providerInfo: {}, - backstageIdentity: { id, idToken }, - }, + response, }); } catch (error) { const { name, message } = isError(error) @@ -105,30 +134,88 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { } async logout(_req: express.Request, res: express.Response): Promise { - res.send('noop'); - } - - identifyEnv(): string | undefined { - return undefined; + res.end(); } } +const samlDefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const id = info.result.fullProfile.nameID; + + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: id }, + }); + + return { id, token }; +}; + type SignatureAlgorithm = 'sha1' | 'sha256' | 'sha512'; -export type SamlProviderOptions = {}; +/** @public */ +export type SamlProviderOptions = { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + signIn?: { + /** + * Maps an auth result to a Backstage identity for the user. + */ + resolver?: SignInResolver; + }; +}; + +/** @public */ export const createSamlProvider = ( - _options?: SamlProviderOptions, + options?: SamlProviderOptions, ): AuthProviderFactory => { - return ({ providerId, globalConfig, config, tokenIssuer }) => { - const opts = { + return ({ + providerId, + globalConfig, + config, + tokenIssuer, + catalogApi, + logger, + }) => { + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile }) => ({ + profile: { + email: fullProfile.email, + displayName: fullProfile.displayName, + }, + }); + + const signInResolverFn = + options?.signIn?.resolver ?? samlDefaultSignInResolver; + + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); + + return new SamlAuthProvider({ callbackUrl: `${globalConfig.baseUrl}/${providerId}/handler/frame`, entryPoint: config.getString('entryPoint'), logoutUrl: config.getOptionalString('logoutUrl'), audience: config.getOptionalString('audience'), issuer: config.getString('issuer'), cert: config.getString('cert'), - privateCert: config.getOptionalString('privateKey'), + privateKey: config.getOptionalString('privateKey'), authnContext: config.getOptionalStringArray('authnContext'), identifierFormat: config.getOptionalString('identifierFormat'), decryptionPvk: config.getOptionalString('decryptionPvk'), @@ -140,8 +227,10 @@ export const createSamlProvider = ( tokenIssuer, appUrl: globalConfig.appUrl, - }; - - return new SamlAuthProvider(opts); + authHandler, + signInResolver, + logger, + catalogIdentityClient, + }); }; }; diff --git a/plugins/azure-devops-backend/CHANGELOG.md b/plugins/azure-devops-backend/CHANGELOG.md index d2fe981eb0..9c94a04a68 100644 --- a/plugins/azure-devops-backend/CHANGELOG.md +++ b/plugins/azure-devops-backend/CHANGELOG.md @@ -1,5 +1,56 @@ # @backstage/plugin-azure-devops-backend +## 0.2.3 + +### Patch Changes + +- 82cd709fdb: **Backend** + + - Created new `/dashboard-pull-requests/:projectName` endpoint + - Created new `/all-teams` endpoint + - Implemented pull request policy evaluation conversion + + **Frontend** + + - Refactored `PullRequestsPage` and added new properties for `projectName` and `pollingInterval` + - Fixed spacing issue between repo link and creation date in `PullRequestCard` + - Added missing condition to `PullRequestCardPolicy` for `RequiredReviewers` + - Updated `useDashboardPullRequests` hook to implement long polling for pull requests + +- Updated dependencies + - @backstage/plugin-azure-devops-common@0.1.1 + - @backstage/backend-common@0.9.12 + +## 0.2.2 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/backend-common@0.9.11 + +## 0.2.1 + +### Patch Changes + +- 2b5ccd2964: Improved Date handling for the Azure DevOps set of plugins by using strings and letting the frontend handle the conversion to DateTime +- Updated dependencies + - @backstage/backend-common@0.9.10 + - @backstage/plugin-azure-devops-common@0.1.0 + ## 0.2.0 ### Minor Changes diff --git a/plugins/azure-devops-backend/README.md b/plugins/azure-devops-backend/README.md index 6101794cd1..79db9f5ff1 100644 --- a/plugins/azure-devops-backend/README.md +++ b/plugins/azure-devops-backend/README.md @@ -63,7 +63,7 @@ Here's how to get the backend up and running: ``` 4. Now run `yarn start-backend` from the repo root -5. Finally open `http://localhost:7000/api/azure-devops/health` in a browser and it should return `{"status":"ok"}` +5. Finally open `http://localhost:7007/api/azure-devops/health` in a browser and it should return `{"status":"ok"}` ## Links diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md index 1eabccc720..19d96dc728 100644 --- a/plugins/azure-devops-backend/api-report.md +++ b/plugins/azure-devops-backend/api-report.md @@ -5,12 +5,14 @@ ```ts import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { Config } from '@backstage/config'; +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; import express from 'express'; import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { Logger as Logger_2 } from 'winston'; import { PullRequest } from '@backstage/plugin-azure-devops-common'; import { PullRequestOptions } from '@backstage/plugin-azure-devops-common'; import { RepoBuild } from '@backstage/plugin-azure-devops-common'; +import { Team } from '@backstage/plugin-azure-devops-common'; import { WebApi } from 'azure-devops-node-api'; // Warning: (ae-missing-release-tag) "AzureDevOpsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -19,12 +21,19 @@ import { WebApi } from 'azure-devops-node-api'; export class AzureDevOpsApi { constructor(logger: Logger_2, webApi: WebApi); // (undocumented) + getAllTeams(): Promise; + // (undocumented) getBuildList( projectName: string, repoId: string, top: number, ): Promise; // (undocumented) + getDashboardPullRequests( + projectName: string, + options: PullRequestOptions, + ): Promise; + // (undocumented) getGitRepository( projectName: string, repoName: string, diff --git a/plugins/azure-devops-backend/package.json b/plugins/azure-devops-backend/package.json index 901ad0b960..c587f97837 100644 --- a/plugins/azure-devops-backend/package.json +++ b/plugins/azure-devops-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-azure-devops-backend", - "version": "0.2.0", + "version": "0.2.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.9", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.11", - "@backstage/plugin-azure-devops-common": "^0.0.2", + "@backstage/plugin-azure-devops-common": "^0.1.1", "@types/express": "^4.17.6", "azure-devops-node-api": "^11.0.1", "express": "^4.17.1", @@ -31,7 +31,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.2", + "@backstage/cli": "^0.10.0", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", "msw": "^0.35.0" diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index 23ea3311f0..53dc19c68d 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -17,19 +17,30 @@ import { BuildResult, BuildStatus, + DashboardPullRequest, + Policy, PullRequest, PullRequestOptions, RepoBuild, + Team, } from '@backstage/plugin-azure-devops-common'; import { GitPullRequest, GitPullRequestSearchCriteria, GitRepository, } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { + convertDashboardPullRequest, + convertPolicy, + getArtifactId, +} from '../utils'; import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { Logger } from 'winston'; +import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; +import { TeamMember } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; import { WebApi } from 'azure-devops-node-api'; +import { WebApiTeam } from 'azure-devops-node-api/interfaces/CoreInterfaces'; export class AzureDevOpsApi { public constructor( @@ -138,6 +149,115 @@ export class AzureDevOpsApi { return pullRequests; } + + public async getDashboardPullRequests( + projectName: string, + options: PullRequestOptions, + ): Promise { + this.logger?.debug( + `Getting dashboard pull requests for project '${projectName}'.`, + ); + + const client = await this.webApi.getGitApi(); + + const searchCriteria: GitPullRequestSearchCriteria = { + status: options.status, + }; + + const gitPullRequests: GitPullRequest[] = + await client.getPullRequestsByProject( + projectName, + searchCriteria, + undefined, + undefined, + options.top, + ); + + return Promise.all( + gitPullRequests.map(async gitPullRequest => { + const projectId = gitPullRequest.repository?.project?.id; + const prId = gitPullRequest.pullRequestId; + + let policies: Policy[] | undefined; + + if (projectId && prId) { + policies = await this.getPullRequestPolicies( + projectName, + projectId, + prId, + ); + } + + return convertDashboardPullRequest( + gitPullRequest, + this.webApi.serverUrl, + policies, + ); + }), + ); + } + + private async getPullRequestPolicies( + projectName: string, + projectId: string, + pullRequestId: number, + ): Promise { + this.logger?.debug( + `Getting pull request policies for pull request id '${pullRequestId}'.`, + ); + + const client = await this.webApi.getPolicyApi(); + + const artifactId = getArtifactId(projectId, pullRequestId); + + const policyEvaluationRecords: PolicyEvaluationRecord[] = + await client.getPolicyEvaluations(projectName, artifactId); + + return policyEvaluationRecords + .map(convertPolicy) + .filter((policy): policy is Policy => Boolean(policy)); + } + + public async getAllTeams(): Promise { + this.logger?.debug('Getting all teams.'); + + const client = await this.webApi.getCoreApi(); + const webApiTeams: WebApiTeam[] = await client.getAllTeams(); + + const teams: Team[] = await Promise.all( + webApiTeams.map(async team => ({ + id: team.id, + name: team.name, + memberIds: await this.getTeamMemberIds(team), + })), + ); + + return teams.sort((a, b) => + a.name && b.name ? a.name.localeCompare(b.name) : 0, + ); + } + + private async getTeamMemberIds( + team: WebApiTeam, + ): Promise { + this.logger?.debug(`Getting team member ids for team '${team.name}'.`); + + if (!team.projectId || !team.id) { + return undefined; + } + + const client = await this.webApi.getCoreApi(); + + const teamMembers: TeamMember[] = + await client.getTeamMembersWithExtendedProperties( + team.projectId, + team.id, + ); + + return teamMembers + .map(teamMember => teamMember.identity?.id) + .filter((id): id is string => Boolean(id)); + } } export function mappedRepoBuild(build: Build): RepoBuild { diff --git a/plugins/azure-devops-backend/src/run.ts b/plugins/azure-devops-backend/src/run.ts index addfdfd6d7..53d4e4334a 100644 --- a/plugins/azure-devops-backend/src/run.ts +++ b/plugins/azure-devops-backend/src/run.ts @@ -18,7 +18,7 @@ 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 port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index d61148b4ff..57d54df18f 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -15,6 +15,7 @@ */ import { + DashboardPullRequest, PullRequestOptions, PullRequestStatus, } from '@backstage/plugin-azure-devops-common'; @@ -27,7 +28,7 @@ import Router from 'express-promise-router'; import { errorHandler } from '@backstage/backend-common'; import express from 'express'; -const DEFAULT_TOP: number = 10; +const DEFAULT_TOP = 10; export interface RouterOptions { azureDevOpsApi?: AzureDevOpsApi; @@ -80,33 +81,69 @@ export async function createRouter( router.get('/repo-builds/:projectName/:repoName', async (req, res) => { const { projectName, repoName } = req.params; + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + const gitRepository = await azureDevOpsApi.getRepoBuilds( projectName, repoName, top, ); + res.status(200).json(gitRepository); }); router.get('/pull-requests/:projectName/:repoName', async (req, res) => { const { projectName, repoName } = req.params; + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + const status = req.query.status ? Number(req.query.status) : PullRequestStatus.Active; + const pullRequestOptions: PullRequestOptions = { top: top, status: status, }; + const gitPullRequest = await azureDevOpsApi.getPullRequests( projectName, repoName, pullRequestOptions, ); + res.status(200).json(gitPullRequest); }); + router.get('/dashboard-pull-requests/:projectName', async (req, res) => { + const { projectName } = req.params; + + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + + const status = req.query.status + ? Number(req.query.status) + : PullRequestStatus.Active; + + const pullRequestOptions: PullRequestOptions = { + top: top, + status: status, + }; + + const pullRequests: DashboardPullRequest[] = + await azureDevOpsApi.getDashboardPullRequests( + projectName, + pullRequestOptions, + ); + + res.status(200).json(pullRequests); + }); + + router.get('/all-teams', async (_req, res) => { + const allTeams = await azureDevOpsApi.getAllTeams(); + res.status(200).json(allTeams); + }); + router.use(errorHandler()); return router; } diff --git a/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts b/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts new file mode 100644 index 0000000000..8aed436710 --- /dev/null +++ b/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DashboardPullRequest, + PullRequestStatus, + PullRequestVoteStatus, +} from '@backstage/plugin-azure-devops-common'; +import { + convertDashboardPullRequest, + getArtifactId, + getAvatarUrl, + getPullRequestLink, +} from './azure-devops-utils'; + +import { GitPullRequest } from 'azure-devops-node-api/interfaces/GitInterfaces'; + +describe('convertDashboardPullRequest', () => { + it('should return DashboardPullRequest', () => { + const baseUrl = 'https://dev.azure.com'; + + const pullRequest: GitPullRequest = { + pullRequestId: 1, + title: 'Pull Request 1', + description: 'Test description', + repository: { + id: 'repo1', + name: 'azure-devops', + url: 'https://dev.azure.com/backstage/backstage/_apis/git/repositories/azure-devops', + project: { + name: 'backstage', + }, + }, + createdBy: { + id: 'user1', + displayName: 'User 1', + uniqueName: 'user1@backstage.io', + _links: { + avatar: { + href: 'avatar-href', + }, + }, + imageUrl: 'avatar-url', + }, + reviewers: [ + { + id: 'user2', + displayName: 'User 2', + _links: { + avatar: { + href: 'avatar-href', + }, + }, + isRequired: true, + isContainer: false, + vote: 10, + }, + ], + creationDate: new Date('2021-10-15T09:30:00.0000000Z'), + status: PullRequestStatus.Active, + isDraft: false, + completionOptions: {}, + }; + + const expectedPullRequest: DashboardPullRequest = { + pullRequestId: 1, + title: 'Pull Request 1', + description: 'Test description', + repository: { + id: 'repo1', + name: 'azure-devops', + url: 'https://dev.azure.com/backstage/backstage/_git/azure-devops', + }, + createdBy: { + id: 'user1', + displayName: 'User 1', + uniqueName: 'user1@backstage.io', + imageUrl: 'avatar-href', + }, + hasAutoComplete: true, + policies: [], + reviewers: [ + { + id: 'user2', + displayName: 'User 2', + imageUrl: 'avatar-href', + isRequired: true, + isContainer: false, + voteStatus: PullRequestVoteStatus.Approved, + }, + ], + creationDate: '2021-10-15T09:30:00.000Z', + status: PullRequestStatus.Active, + isDraft: false, + link: 'https://dev.azure.com/backstage/_git/azure-devops/pullrequest/1', + }; + + const result = convertDashboardPullRequest(pullRequest, baseUrl, []); + expect(result).toEqual(expectedPullRequest); + }); +}); + +describe('getPullRequestLink', () => { + it('should return pull request link', () => { + const baseUrl = 'dev.azure.com'; + const pullRequest = { + pullRequestId: 1, + repository: { + name: 'azure-devops', + project: { + name: 'backstage', + }, + }, + }; + const result = getPullRequestLink(baseUrl, pullRequest); + expect(result).toBe(`${baseUrl}/backstage/_git/azure-devops/pullrequest/1`); + }); +}); + +describe('getAvatarUrl', () => { + it('should return avatar href', () => { + const identity = { + _links: { + avatar: { + href: 'avatar-href', + }, + }, + imageUrl: 'avatar-url', + }; + const result = getAvatarUrl(identity); + expect(result).toBe('avatar-href'); + }); + + it('should return avatar image url', () => { + const identity = { + imageUrl: 'avatar-url', + }; + const result = getAvatarUrl(identity); + expect(result).toBe('avatar-url'); + }); +}); + +describe('getArtifactId', () => { + it('should return artifact id', () => { + const result = getArtifactId('project1', 1); + expect(result).toBe('vstfs:///CodeReview/CodeReviewId/project1/1'); + }); +}); diff --git a/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts b/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts new file mode 100644 index 0000000000..aa843e9c17 --- /dev/null +++ b/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts @@ -0,0 +1,264 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + CreatedBy, + DashboardPullRequest, + Policy, + PolicyEvaluationStatus, + PolicyType, + PolicyTypeId, + PullRequestVoteStatus, + Repository, + Reviewer, +} from '@backstage/plugin-azure-devops-common'; +import { + GitPullRequest, + GitRepository, + IdentityRefWithVote, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; + +import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; +import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; + +export function convertDashboardPullRequest( + pullRequest: GitPullRequest, + baseUrl: string, + policies: Policy[] | undefined, +): DashboardPullRequest { + return { + pullRequestId: pullRequest.pullRequestId, + title: pullRequest.title, + description: pullRequest.description, + repository: convertRepository(pullRequest.repository), + createdBy: convertCreatedBy(pullRequest.createdBy), + hasAutoComplete: hasAutoComplete(pullRequest), + policies, + reviewers: convertReviewers(pullRequest.reviewers), + creationDate: pullRequest.creationDate?.toISOString(), + status: pullRequest.status, + isDraft: pullRequest.isDraft, + link: getPullRequestLink(baseUrl, pullRequest), + }; +} + +export function getPullRequestLink( + baseUrl: string, + pullRequest: GitPullRequest, +): string | undefined { + const projectName = pullRequest.repository?.project?.name; + const repoName = pullRequest.repository?.name; + const pullRequestId = pullRequest.pullRequestId; + + if (!projectName || !repoName || !pullRequestId) { + return undefined; + } + + const encodedProjectName = encodeURIComponent(projectName); + const encodedRepoName = encodeURIComponent(repoName); + + return `${baseUrl}/${encodedProjectName}/_git/${encodedRepoName}/pullrequest/${pullRequestId}`; +} + +/** + * Tries to get the avatar from the new property if not then falls-back to deprecated `imageUrl`. + * https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/get-pull-requests-by-project?view=azure-devops-rest-6.0#identityref + */ +export function getAvatarUrl(identity: IdentityRef): string | undefined { + return identity._links?.avatar?.href ?? identity.imageUrl; +} + +export function getArtifactId( + projectId: string, + pullRequestId: number, +): string { + return `vstfs:///CodeReview/CodeReviewId/${projectId}/${pullRequestId}`; +} + +export function convertPolicy( + policyEvaluationRecord: PolicyEvaluationRecord, +): Policy | undefined { + const policyConfig = policyEvaluationRecord.configuration; + const policyStatus = policyEvaluationRecord.status; + + if (!policyConfig) { + return undefined; + } + + if ( + !( + policyConfig.isEnabled && + !policyConfig.isDeleted && + (policyConfig.isBlocking || + policyConfig.type?.id === PolicyType.Status) && // Optional "Status" policies are actually required for automatic completion. + policyStatus !== PolicyEvaluationStatus.Approved + ) + ) { + return undefined; + } + + const policyTypeId = policyConfig.type?.id; + + if (!policyTypeId) { + return undefined; + } + + const policyType: PolicyType | undefined = ( + { + [PolicyTypeId.Build]: PolicyType.Build, + [PolicyTypeId.Status]: PolicyType.Status, + [PolicyTypeId.MinimumReviewers]: PolicyType.MinimumReviewers, + [PolicyTypeId.Comments]: PolicyType.Comments, + [PolicyTypeId.RequiredReviewers]: PolicyType.RequiredReviewers, + [PolicyTypeId.MergeStrategy]: PolicyType.MergeStrategy, + } as Record + )[policyTypeId]; + + if (!policyType) { + return undefined; + } + + const policyConfigSettings = policyConfig.settings; + let policyText = policyConfig.type?.displayName; + let policyLink: string | undefined; + + switch (policyType) { + case PolicyType.Build: { + const buildDisplayName = policyConfigSettings.displayName; + + if (buildDisplayName) { + policyText += `: ${buildDisplayName}`; + } + + const buildId = policyEvaluationRecord.context?.buildId; + const policyConfigUrl = policyConfig.url; + + if (buildId && policyConfigUrl) { + policyLink = policyConfigUrl.replace( + `_apis/policy/configurations/${policyConfig.id}`, + `_build/results?buildId=${buildId}`, + ); + } + + if (!policyStatus) { + break; + } + + const buildExpired = Boolean(policyConfigSettings.isExpired); + const buildPolicyStatus = + ( + { + [PolicyEvaluationStatus.Queued]: buildExpired + ? 'expired' + : 'queued', + [PolicyEvaluationStatus.Rejected]: 'failed', + } as Record + )[policyStatus] ?? PolicyEvaluationStatus[policyStatus].toLowerCase(); + + policyText += ` (${buildPolicyStatus})`; + + break; + } + case PolicyType.Status: { + const statusGenre = policyConfigSettings.statusGenre; + const statusName = policyConfigSettings.statusGenre; + + if (statusName) { + policyText += `: ${statusGenre}/${statusName}`; + } + + break; + } + case PolicyType.MinimumReviewers: { + const minimumApproverCount = policyConfigSettings.minimumApproverCount; + policyText += ` (${minimumApproverCount})`; + break; + } + case PolicyType.Comments: + break; + case PolicyType.RequiredReviewers: + break; + case PolicyType.MergeStrategy: + default: + return undefined; + } + + return { + id: policyConfig.id, + type: policyType, + status: policyStatus, + text: policyText, + link: policyLink, + }; +} + +function convertReviewer( + identityRef?: IdentityRefWithVote, +): Reviewer | undefined { + if (!identityRef) { + return undefined; + } + + return { + id: identityRef.id, + displayName: identityRef.displayName, + imageUrl: getAvatarUrl(identityRef), + isRequired: identityRef.isRequired, + isContainer: identityRef.isContainer, + voteStatus: (identityRef.vote ?? 0) as PullRequestVoteStatus, + }; +} + +function convertReviewers( + identityRefs?: IdentityRefWithVote[], +): Reviewer[] | undefined { + if (!identityRefs) { + return undefined; + } + + return identityRefs + .map(convertReviewer) + .filter((reviewer): reviewer is Reviewer => Boolean(reviewer)); +} + +function convertRepository(repository?: GitRepository): Repository | undefined { + if (!repository) { + return undefined; + } + + return { + id: repository.id, + name: repository.name, + url: repository.url?.replace('_apis/git/repositories', '_git'), + }; +} + +function convertCreatedBy(identityRef?: IdentityRef): CreatedBy | undefined { + if (!identityRef) { + return undefined; + } + + return { + id: identityRef.id, + displayName: identityRef.displayName, + uniqueName: identityRef.uniqueName, + imageUrl: getAvatarUrl(identityRef), + }; +} + +function hasAutoComplete(pullRequest: GitPullRequest): boolean { + return pullRequest.isDraft !== true && !!pullRequest.completionOptions; +} diff --git a/plugins/azure-devops-backend/src/utils/index.ts b/plugins/azure-devops-backend/src/utils/index.ts new file mode 100644 index 0000000000..415503091a --- /dev/null +++ b/plugins/azure-devops-backend/src/utils/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './azure-devops-utils'; diff --git a/plugins/azure-devops-common/CHANGELOG.md b/plugins/azure-devops-common/CHANGELOG.md index b414cd3ce6..7b2c1be0e7 100644 --- a/plugins/azure-devops-common/CHANGELOG.md +++ b/plugins/azure-devops-common/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-azure-devops-common +## 0.1.1 + +### Patch Changes + +- 0749dd0307: feat: Created pull request card component and initial pull request dashboard page. + +## 0.1.0 + +### Minor Changes + +- 2b5ccd2964: Improved Date handling for the Azure DevOps set of plugins by using strings and letting the frontend handle the conversion to DateTime + ## 0.0.2 ### Patch Changes diff --git a/plugins/azure-devops-common/api-report.md b/plugins/azure-devops-common/api-report.md index 71f0b8b807..17cccb05cb 100644 --- a/plugins/azure-devops-common/api-report.md +++ b/plugins/azure-devops-common/api-report.md @@ -27,6 +27,108 @@ export enum BuildStatus { Postponed = 8, } +// Warning: (ae-missing-release-tag) "CreatedBy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface CreatedBy { + // (undocumented) + displayName?: string; + // (undocumented) + id?: string; + // (undocumented) + imageUrl?: string; + // (undocumented) + uniqueName?: string; +} + +// Warning: (ae-missing-release-tag) "DashboardPullRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface DashboardPullRequest { + // (undocumented) + createdBy?: CreatedBy; + // (undocumented) + creationDate?: string; + // (undocumented) + description?: string; + // (undocumented) + hasAutoComplete: boolean; + // (undocumented) + isDraft?: boolean; + // (undocumented) + link?: string; + // (undocumented) + policies?: Policy[]; + // (undocumented) + pullRequestId?: number; + // (undocumented) + repository?: Repository; + // (undocumented) + reviewers?: Reviewer[]; + // (undocumented) + status?: PullRequestStatus; + // (undocumented) + title?: string; +} + +// Warning: (ae-missing-release-tag) "Policy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface Policy { + // (undocumented) + id?: number; + // (undocumented) + link?: string; + // (undocumented) + status?: PolicyEvaluationStatus; + // (undocumented) + text?: string; + // (undocumented) + type: PolicyType; +} + +// Warning: (ae-missing-release-tag) "PolicyEvaluationStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export enum PolicyEvaluationStatus { + Approved = 2, + Broken = 5, + NotApplicable = 4, + Queued = 0, + Rejected = 3, + Running = 1, +} + +// Warning: (ae-missing-release-tag) "PolicyType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export enum PolicyType { + // (undocumented) + Build = 'Build', + // (undocumented) + Comments = 'Comments', + // (undocumented) + MergeStrategy = 'MergeStrategy', + // (undocumented) + MinimumReviewers = 'MinimumReviewers', + // (undocumented) + RequiredReviewers = 'RequiredReviewers', + // (undocumented) + Status = 'Status', +} + +// Warning: (ae-missing-release-tag) "PolicyTypeId" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export enum PolicyTypeId { + Build = '0609b952-1397-4640-95ec-e00a01b2c241', + Comments = 'c6a1889d-b943-4856-b76f-9e46bb6b0df2', + MergeStrategy = 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab', + MinimumReviewers = 'fa4e907d-c16b-4a4c-9dfa-4906e5d171dd', + RequiredReviewers = 'fd2167ab-b0be-447a-8ec8-39368250530e', + Status = 'cbdc66da-9728-4af8-aada-9a5a32e4a226', +} + // Warning: (ae-missing-release-tag) "PullRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -63,6 +165,22 @@ export enum PullRequestStatus { NotSet = 0, } +// Warning: (ae-missing-release-tag) "PullRequestVoteStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export enum PullRequestVoteStatus { + // (undocumented) + Approved = 10, + // (undocumented) + ApprovedWithSuggestions = 5, + // (undocumented) + NoVote = 0, + // (undocumented) + Rejected = -10, + // (undocumented) + WaitingForAuthor = -5, +} + // Warning: (ae-missing-release-tag) "RepoBuild" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -86,5 +204,47 @@ export type RepoBuildOptions = { top?: number; }; +// Warning: (ae-missing-release-tag) "Repository" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface Repository { + // (undocumented) + id?: string; + // (undocumented) + name?: string; + // (undocumented) + url?: string; +} + +// Warning: (ae-missing-release-tag) "Reviewer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface Reviewer { + // (undocumented) + displayName?: string; + // (undocumented) + id?: string; + // (undocumented) + imageUrl?: string; + // (undocumented) + isContainer?: boolean; + // (undocumented) + isRequired?: boolean; + // (undocumented) + voteStatus: PullRequestVoteStatus; +} + +// Warning: (ae-missing-release-tag) "Team" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface Team { + // (undocumented) + id?: string; + // (undocumented) + memberIds?: string[]; + // (undocumented) + name?: string; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/azure-devops-common/package.json b/plugins/azure-devops-common/package.json index a1ddddcbe0..0b64178a68 100644 --- a/plugins/azure-devops-common/package.json +++ b/plugins/azure-devops-common/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-azure-devops-common", - "version": "0.0.2", + "version": "0.1.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "devDependencies": { - "@backstage/cli": "^0.8.2" + "@backstage/cli": "^0.10.0" }, "files": [ "dist" diff --git a/plugins/azure-devops-common/src/types.ts b/plugins/azure-devops-common/src/types.ts index a647f87300..da41e08d1f 100644 --- a/plugins/azure-devops-common/src/types.ts +++ b/plugins/azure-devops-common/src/types.ts @@ -126,3 +126,128 @@ export type PullRequestOptions = { top: number; status: PullRequestStatus; }; + +export interface DashboardPullRequest { + pullRequestId?: number; + title?: string; + description?: string; + repository?: Repository; + createdBy?: CreatedBy; + hasAutoComplete: boolean; + policies?: Policy[]; + reviewers?: Reviewer[]; + creationDate?: string; + status?: PullRequestStatus; + isDraft?: boolean; + link?: string; +} + +export interface Reviewer { + id?: string; + displayName?: string; + imageUrl?: string; + isRequired?: boolean; + isContainer?: boolean; + voteStatus: PullRequestVoteStatus; +} + +export interface Policy { + id?: number; + type: PolicyType; + status?: PolicyEvaluationStatus; + text?: string; + link?: string; +} + +export interface CreatedBy { + id?: string; + displayName?: string; + uniqueName?: string; + imageUrl?: string; +} + +export interface Repository { + id?: string; + name?: string; + url?: string; +} + +export interface Team { + id?: string; + name?: string; + memberIds?: string[]; +} + +/** + * Status of a policy which is running against a specific pull request. + */ +export enum PolicyEvaluationStatus { + /** + * The policy is either queued to run, or is waiting for some event before progressing. + */ + Queued = 0, + /** + * The policy is currently running. + */ + Running = 1, + /** + * The policy has been fulfilled for this pull request. + */ + Approved = 2, + /** + * The policy has rejected this pull request. + */ + Rejected = 3, + /** + * The policy does not apply to this pull request. + */ + NotApplicable = 4, + /** + * The policy has encountered an unexpected error. + */ + Broken = 5, +} + +export enum PolicyType { + Build = 'Build', + Status = 'Status', + MinimumReviewers = 'MinimumReviewers', + Comments = 'Comments', + RequiredReviewers = 'RequiredReviewers', + MergeStrategy = 'MergeStrategy', +} + +export enum PolicyTypeId { + /** + * This policy will require a successful build has been performed before updating protected refs. + */ + Build = '0609b952-1397-4640-95ec-e00a01b2c241', + /** + * This policy will require a successful status to be posted before updating protected refs. + */ + Status = 'cbdc66da-9728-4af8-aada-9a5a32e4a226', + /** + * This policy will ensure that a minimum number of reviewers have approved a pull request before completion. + */ + MinimumReviewers = 'fa4e907d-c16b-4a4c-9dfa-4906e5d171dd', + /** + * Check if the pull request has any active comments. + */ + Comments = 'c6a1889d-b943-4856-b76f-9e46bb6b0df2', + /** + * This policy will ensure that required reviewers are added for modified files matching specified patterns. + */ + RequiredReviewers = 'fd2167ab-b0be-447a-8ec8-39368250530e', + /** + * This policy ensures that pull requests use a consistent merge strategy. + */ + MergeStrategy = 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab', +} + +export enum PullRequestVoteStatus { + Approved = 10, + ApprovedWithSuggestions = 5, + NoVote = 0, + WaitingForAuthor = -5, + Rejected = -10, +} diff --git a/plugins/azure-devops/CHANGELOG.md b/plugins/azure-devops/CHANGELOG.md index 2f75f64236..9f460d4837 100644 --- a/plugins/azure-devops/CHANGELOG.md +++ b/plugins/azure-devops/CHANGELOG.md @@ -1,5 +1,43 @@ # @backstage/plugin-azure-devops +## 0.1.5 + +### Patch Changes + +- 0749dd0307: feat: Created pull request card component and initial pull request dashboard page. +- 82cd709fdb: **Backend** + + - Created new `/dashboard-pull-requests/:projectName` endpoint + - Created new `/all-teams` endpoint + - Implemented pull request policy evaluation conversion + + **Frontend** + + - Refactored `PullRequestsPage` and added new properties for `projectName` and `pollingInterval` + - Fixed spacing issue between repo link and creation date in `PullRequestCard` + - Added missing condition to `PullRequestCardPolicy` for `RequiredReviewers` + - Updated `useDashboardPullRequests` hook to implement long polling for pull requests + +- Updated dependencies + - @backstage/plugin-azure-devops-common@0.1.1 + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.1.4 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- b5eac957f2: Added entity view for Azure Repo Pull Requests +- 2b5ccd2964: Improved Date handling for the Azure DevOps set of plugins by using strings and letting the frontend handle the conversion to DateTime +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/plugin-azure-devops-common@0.1.0 + ## 0.1.3 ### Patch Changes diff --git a/plugins/azure-devops/README.md b/plugins/azure-devops/README.md index 47a0c4debd..7b39dae443 100644 --- a/plugins/azure-devops/README.md +++ b/plugins/azure-devops/README.md @@ -6,10 +6,16 @@ Website: [https://dev.azure.com/](https://dev.azure.com/) ### Azure Pipelines -Lists the top _n_ builds for a given repository where _n_ is a configurable value +Lists the top _n_ builds for a given Azure Repo where _n_ is a configurable value ![Azure Pipelines Builds Example](./docs/azure-devops-builds.png) +### Azure Repos + +Lists the top _n_ Active, Completed, or Abandoned Pull Requests for a given repository where _n_ is a configurable value + +![Azure Repos Pull Requests Example](./docs/azure-devops-pull-requests.png) + ## Setup The following sections will help you get the Azure DevOps plugin setup and running @@ -69,14 +75,55 @@ To get the Azure Pipelines component working you'll need to do the following two // ... - // Set defaultLimit to the max number of builds you would like to be able to see - // the default if not set is 10 // ... ``` +**Notes:** + +- The `if` prop is optional on the `EntitySwitch.Case`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation +- The `defaultLimit` property on the `EntityAzurePipelinesContent` will set the max number of Builds you would like to see, if not set this will default to 10 + +### Azure Repos Component + +To get the Azure Repos component working you'll need to do the following two steps: + +1. First we need to add the @backstage/plugin-azure-devops package to your frontend app: + + ```bash + # From your Backstage root directory + cd packages/app + yarn add @backstage/plugin-azure-devops + ``` + +2. Second we need to add the `EntityAzurePullRequestsContent` extension to the entity page in your app: + + ```tsx + // In packages/app/src/components/catalog/EntityPage.tsx + import { + EntityAzurePullRequestsContent, + isAzureDevOpsAvailable, + } from '@backstage/plugin-azure-devops'; + + // For example in the Service section + const serviceEntityPage = ( + + // ... + + + + // ... + + ``` + +**Notes:** + +- You'll need to add the `EntityLayout.Route` above from step 2 to all the entity sections you want to see Pull Requests in. For example if you wanted to see Pull Requests when looking at Website entities then you would need to add this to the `websiteEntityPage` section. +- The `if` prop is optional on the `EntityLayout.Route`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation +- The `defaultLimit` property on the `EntityAzurePullRequestsContent` will set the max number of Pull Requests you would like to see, if not set this will default to 10 + ## Limitations - Currently multiple organizations are not supported diff --git a/plugins/azure-devops/api-report.md b/plugins/azure-devops/api-report.md index fbe28c179c..0f3e3310ef 100644 --- a/plugins/azure-devops/api-report.md +++ b/plugins/azure-devops/api-report.md @@ -7,17 +7,28 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { RouteRef } from '@backstage/core-plugin-api'; +import { SvgIconProps } from '@material-ui/core'; // Warning: (ae-missing-release-tag) "azureDevOpsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const azureDevOpsPlugin: BackstagePlugin< - { - entityContent: RouteRef; - }, - {} ->; +export const azureDevOpsPlugin: BackstagePlugin<{}, {}>; + +// Warning: (ae-missing-release-tag) "AzurePullRequestsIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const AzurePullRequestsIcon: (props: SvgIconProps) => JSX.Element; + +// Warning: (ae-missing-release-tag) "AzurePullRequestsPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const AzurePullRequestsPage: ({ + projectName, + pollingInterval, +}: { + projectName?: string | undefined; + pollingInterval?: number | undefined; +}) => JSX.Element; // Warning: (ae-missing-release-tag) "EntityAzurePipelinesContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -28,6 +39,15 @@ export const EntityAzurePipelinesContent: ({ defaultLimit?: number | undefined; }) => JSX.Element; +// Warning: (ae-missing-release-tag) "EntityAzurePullRequestsContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const EntityAzurePullRequestsContent: ({ + defaultLimit, +}: { + defaultLimit?: number | undefined; +}) => JSX.Element; + // Warning: (ae-missing-release-tag) "isAzureDevOpsAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/azure-devops/docs/azure-devops-builds.png b/plugins/azure-devops/docs/azure-devops-builds.png index 4788a185ed..6ff93dc7e4 100644 Binary files a/plugins/azure-devops/docs/azure-devops-builds.png and b/plugins/azure-devops/docs/azure-devops-builds.png differ diff --git a/plugins/azure-devops/docs/azure-devops-pull-requests.png b/plugins/azure-devops/docs/azure-devops-pull-requests.png new file mode 100644 index 0000000000..ce1b2a4267 Binary files /dev/null and b/plugins/azure-devops/docs/azure-devops-pull-requests.png differ diff --git a/plugins/azure-devops/package.json b/plugins/azure-devops/package.json index 235304c9fa..29f133ab5b 100644 --- a/plugins/azure-devops/package.json +++ b/plugins/azure-devops/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-azure-devops", - "version": "0.1.3", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,16 +27,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", "@backstage/errors": "^0.1.4", - "@backstage/plugin-azure-devops-common": "^0.0.2", - "@backstage/plugin-catalog-react": "^0.6.3", - "@backstage/theme": "^0.2.13", + "@backstage/plugin-azure-devops-common": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", + "humanize-duration": "^3.27.0", "luxon": "^2.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -44,10 +45,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts index 05377b0b7b..80d36f0adf 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsApi.ts @@ -15,8 +15,12 @@ */ import { + DashboardPullRequest, + PullRequest, + PullRequestOptions, RepoBuild, RepoBuildOptions, + Team, } from '@backstage/plugin-azure-devops-common'; import { createApiRef } from '@backstage/core-plugin-api'; @@ -33,4 +37,16 @@ export interface AzureDevOpsApi { repoName: string, options?: RepoBuildOptions, ): Promise<{ items: RepoBuild[] }>; + + getPullRequests( + projectName: string, + repoName: string, + options?: PullRequestOptions, + ): Promise<{ items: PullRequest[] }>; + + getDashboardPullRequests( + projectName: string, + ): Promise; + + getAllTeams(): Promise; } diff --git a/plugins/azure-devops/src/api/AzureDevOpsClient.ts b/plugins/azure-devops/src/api/AzureDevOpsClient.ts index 4fd4e781c4..28d7e19cff 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsClient.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsClient.ts @@ -14,11 +14,15 @@ * limitations under the License. */ -import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { + DashboardPullRequest, + PullRequest, + PullRequestOptions, RepoBuild, RepoBuildOptions, + Team, } from '@backstage/plugin-azure-devops-common'; +import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { AzureDevOpsApi } from './AzureDevOpsApi'; import { ResponseError } from '@backstage/errors'; @@ -27,7 +31,7 @@ export class AzureDevOpsClient implements AzureDevOpsApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; - constructor(options: { + public constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; }) { @@ -35,17 +39,55 @@ export class AzureDevOpsClient implements AzureDevOpsApi { this.identityApi = options.identityApi; } - async getRepoBuilds( + public async getRepoBuilds( projectName: string, repoName: string, options?: RepoBuildOptions, ): Promise<{ items: RepoBuild[] }> { - const items = await this.get( - `repo-builds/${projectName}/${repoName}?top=${options?.top}`, - ); + const queryString = new URLSearchParams(); + if (options?.top) { + queryString.append('top', options.top.toString()); + } + const urlSegment = `repo-builds/${encodeURIComponent( + projectName, + )}/${encodeURIComponent(repoName)}?${queryString}`; + + const items = await this.get(urlSegment); return { items }; } + public async getPullRequests( + projectName: string, + repoName: string, + options?: PullRequestOptions, + ): Promise<{ items: PullRequest[] }> { + const queryString = new URLSearchParams(); + if (options?.top) { + queryString.append('top', options.top.toString()); + } + if (options?.status) { + queryString.append('status', options.status.toString()); + } + const urlSegment = `pull-requests/${encodeURIComponent( + projectName, + )}/${encodeURIComponent(repoName)}?${queryString}`; + + const items = await this.get(urlSegment); + return { items }; + } + + public getDashboardPullRequests( + projectName: string, + ): Promise { + return this.get( + `dashboard-pull-requests/${projectName}?top=100`, + ); + } + + public getAllTeams(): Promise { + return this.get('all-teams'); + } + private async get(path: string): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('azure-devops')}/`; const url = new URL(path, baseUrl); diff --git a/plugins/azure-devops/src/components/AzurePipelinesIcon/AzurePipelinesIcon.tsx b/plugins/azure-devops/src/components/AzurePipelinesIcon/AzurePipelinesIcon.tsx new file mode 100644 index 0000000000..bd19331f6e --- /dev/null +++ b/plugins/azure-devops/src/components/AzurePipelinesIcon/AzurePipelinesIcon.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SvgIcon, SvgIconProps } from '@material-ui/core'; + +import React from 'react'; + +export const AzurePipelinesIcon = (props: SvgIconProps) => ( + + + + +); diff --git a/plugins/azure-devops/src/components/AzurePipelinesIcon/index.ts b/plugins/azure-devops/src/components/AzurePipelinesIcon/index.ts new file mode 100644 index 0000000000..4eb161f7bc --- /dev/null +++ b/plugins/azure-devops/src/components/AzurePipelinesIcon/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AzurePipelinesIcon } from './AzurePipelinesIcon'; diff --git a/plugins/azure-devops/src/components/AzurePullRequestsIcon/AzurePullRequestsIcon.tsx b/plugins/azure-devops/src/components/AzurePullRequestsIcon/AzurePullRequestsIcon.tsx new file mode 100644 index 0000000000..bb4cb8c081 --- /dev/null +++ b/plugins/azure-devops/src/components/AzurePullRequestsIcon/AzurePullRequestsIcon.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SvgIcon, SvgIconProps } from '@material-ui/core'; + +import React from 'react'; + +export const AzurePullRequestsIcon = (props: SvgIconProps) => ( + + + + + + + +); diff --git a/plugins/azure-devops/src/components/AzurePullRequestsIcon/index.ts b/plugins/azure-devops/src/components/AzurePullRequestsIcon/index.ts new file mode 100644 index 0000000000..1f49119b4b --- /dev/null +++ b/plugins/azure-devops/src/components/AzurePullRequestsIcon/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AzurePullRequestsIcon } from './AzurePullRequestsIcon'; diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx index c16c836393..463bf15d78 100644 --- a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx +++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx @@ -33,8 +33,10 @@ import { TableColumn, } from '@backstage/core-components'; +import { AzurePipelinesIcon } from '../AzurePipelinesIcon'; import { DateTime } from 'luxon'; import React from 'react'; +import { getDurationFromDates } from '../../utils/getDurationFromDates'; export const getBuildResultComponent = (result: number | undefined) => { switch (result) { @@ -144,6 +146,18 @@ const columns: TableColumn[] = [ ), }, + { + title: 'Duration', + field: 'queueTime', + width: 'auto', + render: (row: Partial) => ( + + + {getDurationFromDates(row.startTime, row.finishTime)} + + + ), + }, { title: 'Age', field: 'queueTime', @@ -181,7 +195,13 @@ export const BuildTable = ({ items, loading, error }: BuildTableProps) => { pageSize: 5, showEmptyDataSourceMessage: !loading, }} - title={`Builds (${items ? items.length : 0})`} + title={ + + + + Azure Pipelines - Builds ({items ? items.length : 0}) + + } data={items ?? []} /> ); diff --git a/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx new file mode 100644 index 0000000000..3d1a3ef77a --- /dev/null +++ b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/EntityPageAzurePullRequests.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PullRequestTable } from '../PullRequestTable/PullRequestTable'; +import React from 'react'; + +export const EntityPageAzurePullRequests = ({ + defaultLimit, +}: { + defaultLimit?: number; +}) => { + return ; +}; diff --git a/plugins/azure-devops/src/components/EntityPageAzurePullRequests/index.ts b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/index.ts new file mode 100644 index 0000000000..2ec6c5bf04 --- /dev/null +++ b/plugins/azure-devops/src/components/EntityPageAzurePullRequests/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { EntityPageAzurePullRequests } from './EntityPageAzurePullRequests'; diff --git a/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/PullRequestStatusButtonGroup.tsx b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/PullRequestStatusButtonGroup.tsx new file mode 100644 index 0000000000..f516bfb0e5 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/PullRequestStatusButtonGroup.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button, ButtonGroup } from '@material-ui/core'; + +import { PullRequestStatus } from '@backstage/plugin-azure-devops-common'; +import React from 'react'; + +export const PullRequestStatusButtonGroup = ({ + status, + setStatus, +}: { + status: PullRequestStatus; + setStatus: (pullRequestStatus: PullRequestStatus) => void; +}) => { + return ( + + + + + + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/index.ts b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/index.ts new file mode 100644 index 0000000000..10a667803a --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestStatusButtonGroup/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestStatusButtonGroup } from './PullRequestStatusButtonGroup'; diff --git a/plugins/azure-devops/src/components/PullRequestTable/PullRequestTable.tsx b/plugins/azure-devops/src/components/PullRequestTable/PullRequestTable.tsx new file mode 100644 index 0000000000..4365b796bc --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestTable/PullRequestTable.tsx @@ -0,0 +1,139 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Box, Chip } from '@material-ui/core'; +import { + Link, + ResponseErrorPanel, + Table, + TableColumn, +} from '@backstage/core-components'; +import { + PullRequest, + PullRequestStatus, +} from '@backstage/plugin-azure-devops-common'; +import React, { useState } from 'react'; + +import { AzurePullRequestsIcon } from '../AzurePullRequestsIcon'; +import { DateTime } from 'luxon'; +import { PullRequestStatusButtonGroup } from '../PullRequestStatusButtonGroup'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { usePullRequests } from '../../hooks/usePullRequests'; + +const columns: TableColumn[] = [ + { + title: 'ID', + field: 'pullRequestId', + highlight: false, + width: 'auto', + }, + { + title: 'Title', + field: 'title', + width: 'auto', + render: (row: Partial) => ( + + {row.title} + {row.isDraft && ( + + + + )} + + ), + }, + { + title: 'Source', + field: 'sourceRefName', + width: 'auto', + }, + { + title: 'Target', + field: 'targetRefName', + width: 'auto', + }, + { + title: 'Created By', + field: 'createdBy', + width: 'auto', + }, + { + title: 'Created', + field: 'creationDate', + width: 'auto', + render: (row: Partial) => + (row.creationDate + ? DateTime.fromISO(row.creationDate) + : DateTime.now() + ).toRelative(), + }, +]; + +type PullRequestTableProps = { + defaultLimit?: number; +}; + +export const PullRequestTable = ({ defaultLimit }: PullRequestTableProps) => { + const [pullRequestStatusState, setPullRequestStatusState] = + useState(PullRequestStatus.Active); + const { entity } = useEntity(); + + const { items, loading, error } = usePullRequests( + entity, + defaultLimit, + pullRequestStatusState, + ); + + if (error) { + return ( +
+ +
+ ); + } + + return ( + + + + Azure Repos - Pull Requests ({items ? items.length : 0}) + + + + + } + data={items ?? []} + /> + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestTable/index.ts b/plugins/azure-devops/src/components/PullRequestTable/index.ts new file mode 100644 index 0000000000..956e8afa2d --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestTable/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestTable } from './PullRequestTable'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx b/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx new file mode 100644 index 0000000000..9f21c4b81b --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, + Page, + Progress, + ResponseErrorPanel, +} from '@backstage/core-components'; +import { PullRequestGroup, PullRequestGroupConfig } from './lib/types'; +import React, { useEffect, useState } from 'react'; +import { getCreatedByUserFilter, getPullRequestGroups } from './lib/utils'; +import { useDashboardPullRequests, useUserEmail } from '../../hooks'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { PullRequestGrid } from './lib/PullRequestGrid'; + +function usePullRequestGroupConfigs( + userEmail: string | undefined, +): PullRequestGroupConfig[] { + const [pullRequestGroupConfigs, setPullRequestGroupConfigs] = useState< + PullRequestGroupConfig[] + >([]); + + useEffect(() => { + const prGroupConfigs: PullRequestGroupConfig[] = [ + { title: 'Created by me', filter: getCreatedByUserFilter(userEmail) }, + { title: 'Other PRs', filter: _ => true, simplified: false }, + ]; + + setPullRequestGroupConfigs(prGroupConfigs); + }, [userEmail]); + + return pullRequestGroupConfigs; +} + +function usePullRequestGroups( + pullRequests: DashboardPullRequest[] | undefined, + pullRequestGroupConfigs: PullRequestGroupConfig[], +): PullRequestGroup[] { + const [pullRequestGroups, setPullRequestGroups] = useState< + PullRequestGroup[] + >([]); + + useEffect(() => { + if (pullRequests) { + const groups = getPullRequestGroups( + pullRequests, + pullRequestGroupConfigs, + ); + setPullRequestGroups(groups); + } + }, [pullRequests, pullRequestGroupConfigs]); + + return pullRequestGroups; +} + +type PullRequestsPageContentProps = { + pullRequestGroups: PullRequestGroup[]; + loading: boolean; + error?: Error; +}; + +const PullRequestsPageContent = ({ + pullRequestGroups, + loading, + error, +}: PullRequestsPageContentProps) => { + if (loading && pullRequestGroups.length <= 0) { + return ; + } + + if (error) { + return ; + } + + return ; +}; + +type PullRequestsPageProps = { + projectName?: string; + pollingInterval?: number; +}; + +export const PullRequestsPage = ({ + projectName, + pollingInterval, +}: PullRequestsPageProps) => { + const { pullRequests, loading, error } = useDashboardPullRequests( + projectName, + pollingInterval, + ); + const userEmail = useUserEmail(); + const pullRequestGroupConfigs = usePullRequestGroupConfigs(userEmail); + const pullRequestGroups = usePullRequestGroups( + pullRequests, + pullRequestGroupConfigs, + ); + + return ( + +
+ + + + + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/index.ts new file mode 100644 index 0000000000..e8b6cfa6bb --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestsPage } from './PullRequestsPage'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/AutoCompleteIcon.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/AutoCompleteIcon.tsx new file mode 100644 index 0000000000..ca43b8e27b --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/AutoCompleteIcon.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import DoneAllIcon from '@material-ui/icons/DoneAll'; +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(theme => ({ + root: (props: { hasAutoComplete: boolean }) => ({ + color: props.hasAutoComplete + ? theme.palette.success.main + : theme.palette.grey[400], + }), +})); + +export const AutoCompleteIcon = (props: { hasAutoComplete: boolean }) => { + const classes = useStyles(props); + return ; +}; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/index.ts new file mode 100644 index 0000000000..d83b0ffad0 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/AutoCompleteIcon/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AutoCompleteIcon } from './AutoCompleteIcon'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.stories.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.stories.tsx new file mode 100644 index 0000000000..b948b616bd --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.stories.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DashboardPullRequest, + PolicyEvaluationStatus, + PolicyType, + PullRequestStatus, + PullRequestVoteStatus, +} from '@backstage/plugin-azure-devops-common'; + +import { MemoryRouter } from 'react-router'; +import { PullRequestCard } from './PullRequestCard'; +import React from 'react'; + +export default { + title: 'Plugins/Azure Devops/Pull Request Card', + component: PullRequestCard, +}; + +const pullRequest: DashboardPullRequest = { + pullRequestId: 1, + title: + "feat(EXUX-4091): 🛂 Added the admin role authorization to the backend API's", + description: + 'This PR contains the following updates:\n\n| Package | Type | Update | Change |\n|---|---|---|---|\n| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint) | devDependencies | major | [`4.33.0` -> `5.0.0`](https://renovatebot.com/diffs/npm/@typescript-eslint%2feslint-plugin/4.33.0/5.0.0) |\n| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescrip', + link: undefined, + repository: { + id: undefined, + name: 'backstage', + url: undefined, + }, + createdBy: { + id: '', + displayName: 'Marley', + uniqueName: 'marley@test.com', + imageUrl: + 'https://dev.azure.com/exclaimerltd/_api/_common/identityImage?id=e6c0634b-68d2-6e6f-aa7d-adccada23216', + }, + reviewers: [ + { + id: undefined, + displayName: 'Marley', + imageUrl: '', + isRequired: true, + isContainer: false, + voteStatus: PullRequestVoteStatus.Approved, + }, + { + id: undefined, + displayName: 'User 1', + imageUrl: '', + isRequired: false, + isContainer: false, + voteStatus: PullRequestVoteStatus.WaitingForAuthor, + }, + { + id: undefined, + displayName: 'User 2', + imageUrl: '', + isRequired: true, + isContainer: false, + voteStatus: PullRequestVoteStatus.NoVote, + }, + ], + policies: [ + { + id: undefined, + type: PolicyType.Build, + status: PolicyEvaluationStatus.Running, + text: 'Build: UI (running)', + link: undefined, + }, + { + id: undefined, + type: PolicyType.MinimumReviewers, + text: 'Minimum number of reviewers (2)', + status: undefined, + link: undefined, + }, + { + id: undefined, + type: PolicyType.Comments, + text: 'Comment requirements', + status: undefined, + link: undefined, + }, + ], + hasAutoComplete: true, + creationDate: new Date(Date.now() - 10000000).toISOString(), + status: PullRequestStatus.Active, + isDraft: false, +}; + +export const Default = () => ( + + + +); + +export const Simplified = () => ( + + + +); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.tsx new file mode 100644 index 0000000000..4b2823814e --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.tsx @@ -0,0 +1,130 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Avatar, Link } from '@backstage/core-components'; +import { Card, CardContent, CardHeader } from '@material-ui/core'; + +import { AutoCompleteIcon } from '../AutoCompleteIcon'; +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { DateTime } from 'luxon'; +import { PullRequestCardPolicies } from './PullRequestCardPolicies'; +import { PullRequestCardReviewers } from './PullRequestCardReviewers'; +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles( + theme => ({ + card: { + backgroundColor: + theme.palette.type === 'dark' + ? theme.palette.grey[700] + : theme.palette.common.white, + }, + cardHeaderSimplified: { + paddingBottom: theme.spacing(2), + }, + cardHeaderAction: { + display: 'flex', + alignSelf: 'center', + margin: 0, + }, + content: { + display: 'flex', + flexDirection: 'row', + }, + policies: { + flex: 1, + }, + }), + { name: 'PullRequestCard' }, +); + +type PullRequestCardProps = { + pullRequest: DashboardPullRequest; + simplified?: boolean; +}; + +export const PullRequestCard = ({ + pullRequest, + simplified, +}: PullRequestCardProps) => { + const title = ( + + {pullRequest.title} + + ); + + const repoLink = ( + + {pullRequest.repository?.name} + + ); + + const creationDate = pullRequest.creationDate + ? DateTime.fromISO(pullRequest.creationDate).toRelative() + : undefined; + + const subheader = ( + + {repoLink} · {creationDate} + + ); + + const avatar = ( + + ); + + const classes = useStyles(); + + return ( + + + } + classes={{ + ...(simplified && { root: classes.cardHeaderSimplified }), + action: classes.cardHeaderAction, + }} + /> + + {!simplified && ( + + {pullRequest.policies && ( + + )} + + {pullRequest.reviewers && ( + + )} + + )} + + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicies.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicies.tsx new file mode 100644 index 0000000000..ec6389ab1e --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicies.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Policy } from '@backstage/plugin-azure-devops-common'; +import { PullRequestCardPolicy } from './PullRequestCardPolicy'; +import React from 'react'; + +type PullRequestCardProps = { + policies: Policy[]; + className: string; +}; + +export const PullRequestCardPolicies = ({ + policies, + className, +}: PullRequestCardProps) => ( +
+ {policies.map(policy => ( + + ))} +
+); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicy.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicy.tsx new file mode 100644 index 0000000000..580f8c571f --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicy.tsx @@ -0,0 +1,96 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Policy, + PolicyEvaluationStatus, + PolicyType, +} from '@backstage/plugin-azure-devops-common'; +import { styled, withStyles } from '@material-ui/core/styles'; + +import CancelIcon from '@material-ui/icons/Cancel'; +import GroupWorkIcon from '@material-ui/icons/GroupWork'; +import React from 'react'; +import WatchLaterIcon from '@material-ui/icons/WatchLater'; + +const PolicyRequiredIcon = withStyles( + theme => ({ + root: { + color: theme.palette.info.main, + }, + }), + { name: 'PolicyRequiredIcon' }, +)(WatchLaterIcon); + +const PolicyIssueIcon = withStyles( + theme => ({ + root: { + color: theme.palette.error.main, + }, + }), + { name: 'PolicyIssueIcon' }, +)(CancelIcon); + +const PolicyInProgressIcon = withStyles( + theme => ({ + root: { + color: theme.palette.info.main, + }, + }), + { name: 'PolicyInProgressIcon' }, +)(GroupWorkIcon); + +function getPolicyIcon(policy: Policy): JSX.Element | null { + switch (policy.type) { + case PolicyType.Build: + switch (policy.status) { + case PolicyEvaluationStatus.Running: + return ; + case PolicyEvaluationStatus.Rejected: + return ; + case PolicyEvaluationStatus.Queued: + return ; + default: + return null; + } + case PolicyType.MinimumReviewers: + case PolicyType.RequiredReviewers: + return ; + case PolicyType.Status: + case PolicyType.Comments: + return ; + default: + return null; + } +} + +const PullRequestCardPolicyContainer = styled('div')({ + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', +}); + +type PullRequestCardPolicyProps = { + policy: Policy; +}; + +export const PullRequestCardPolicy = ({ + policy, +}: PullRequestCardPolicyProps) => ( + + {getPolicyIcon(policy)} {policy.text} + +); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewer.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewer.tsx new file mode 100644 index 0000000000..1d2049728b --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewer.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Avatar } from '@backstage/core-components'; +import React from 'react'; +import { Reviewer } from '@backstage/plugin-azure-devops-common'; + +type PullRequestCardReviewerProps = { + reviewer: Reviewer; +}; + +export const PullRequestCardReviewer = ({ + reviewer, +}: PullRequestCardReviewerProps) => ( + +); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewers.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewers.tsx new file mode 100644 index 0000000000..e3bf0c8a03 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardReviewers.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PullRequestCardReviewer } from './PullRequestCardReviewer'; +import React from 'react'; +import { Reviewer } from '@backstage/plugin-azure-devops-common'; +import { reviewerFilter } from '../utils'; +import { styled } from '@material-ui/core/styles'; + +const PullRequestCardReviewersContainer = styled('div')({ + '& > *': { + marginTop: 4, + marginBottom: 4, + }, +}); + +type PullRequestCardReviewersProps = { + reviewers: Reviewer[]; +}; + +export const PullRequestCardReviewers = ({ + reviewers, +}: PullRequestCardReviewersProps) => ( + + {reviewers.filter(reviewerFilter).map(reviewer => ( + + ))} + +); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/index.ts new file mode 100644 index 0000000000..5d6a49d09e --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestCard } from './PullRequestCard'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/PullRequestGrid.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/PullRequestGrid.tsx new file mode 100644 index 0000000000..8b9c49beb4 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/PullRequestGrid.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PullRequestGridColumn } from '../PullRequestGridColumn'; +import { PullRequestGroup } from '../types'; +import React from 'react'; +import { styled } from '@material-ui/core'; + +const GridDiv = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + '& > *': { + marginRight: theme.spacing(2), + }, + '& > :last-of-type': { + marginRight: 0, + }, +})); + +type PullRequestGridProps = { + pullRequestGroups: PullRequestGroup[]; +}; + +export const PullRequestGrid = ({ + pullRequestGroups, +}: PullRequestGridProps) => { + return ( + + {pullRequestGroups.map((pullRequestGroup, index) => ( + + ))} + + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/index.ts new file mode 100644 index 0000000000..902680f80d --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGrid/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestGrid } from './PullRequestGrid'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/PullRequestGridColumn.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/PullRequestGridColumn.tsx new file mode 100644 index 0000000000..94a648188d --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/PullRequestGridColumn.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Paper, Typography, styled, withStyles } from '@material-ui/core'; + +import { PullRequestCard } from '../PullRequestCard'; +import { PullRequestGroup } from '../types'; +import React from 'react'; + +const ColumnPaper = withStyles(theme => ({ + root: { + display: 'flex', + flexDirection: 'column', + flex: 1, + padding: theme.spacing(2), + backgroundColor: + theme.palette.type === 'dark' + ? theme.palette.grey[800] + : theme.palette.grey[300], + height: '100%', + }, +}))(Paper); + +const ColumnTitleDiv = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: theme.spacing(2), +})); + +export const PullRequestCardContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& > *': { + marginBottom: theme.spacing(2), + }, + '& > :last-of-type': { + marginBottom: 0, + }, +})); + +type PullRequestGridColumnProps = { + pullRequestGroup: PullRequestGroup; +}; + +export const PullRequestGridColumn = ({ + pullRequestGroup, +}: PullRequestGridColumnProps) => { + const columnTitle = ( + + {pullRequestGroup.title} + + ); + + const pullRequests = ( + + {pullRequestGroup.pullRequests.map(pullRequest => ( + + ))} + + ); + + return ( + + {columnTitle} + {pullRequests} + + ); +}; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/index.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/index.ts new file mode 100644 index 0000000000..aa3cf82554 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestGridColumn/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PullRequestGridColumn } from './PullRequestGridColumn'; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/types.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/types.ts new file mode 100644 index 0000000000..2d936f7b64 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/types.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DashboardPullRequest, + Team, +} from '@backstage/plugin-azure-devops-common'; + +export interface PullRequestGroup { + title: string; + pullRequests: DashboardPullRequest[]; + simplified?: boolean; +} + +export type PullRequestFilter = (pullRequest: DashboardPullRequest) => boolean; + +export type TeamFilter = (team: Team) => boolean; + +export interface PullRequestGroupConfig { + title: string; + filter: PullRequestFilter; + simplified?: boolean; +} diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.test.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.test.ts new file mode 100644 index 0000000000..da27b2e06e --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DashboardPullRequest, + PullRequestVoteStatus, + Reviewer, +} from '@backstage/plugin-azure-devops-common'; +import { + arrayExtract, + getCreatedByUserFilter, + getPullRequestGroups, + reviewerFilter, +} from './utils'; + +describe('getCreatedByUserFilter', () => { + it('should filter if pull request is created by user', () => { + const userEmail = 'user1@backstage.com'; + const pr = { + createdBy: { uniqueName: userEmail }, + } as DashboardPullRequest; + const result = getCreatedByUserFilter(userEmail)(pr); + expect(result).toBe(true); + }); + + it('should not filter if pull request is not created by user', () => { + const userEmail1 = 'user1@backstage.com'; + const userEmail2 = 'user2@backstage.com'; + const pr = { + createdBy: { uniqueName: userEmail1 }, + } as DashboardPullRequest; + const result = getCreatedByUserFilter(userEmail2)(pr); + expect(result).toBe(false); + }); +}); + +describe('reviewerFilter', () => { + it('should return false if reviewer has no vote and is not required', () => { + const reviewer = { + voteStatus: PullRequestVoteStatus.NoVote, + isRequired: false, + } as Reviewer; + const result = reviewerFilter(reviewer); + expect(result).toBe(false); + }); + + it('should return true if reviewer has no vote and is required', () => { + const reviewer = { + voteStatus: PullRequestVoteStatus.NoVote, + isRequired: true, + } as Reviewer; + const result = reviewerFilter(reviewer); + expect(result).toBe(true); + }); + + it('should return true if reviewer has a vote and is not a container', () => { + const reviewer = { + voteStatus: PullRequestVoteStatus.Approved, + isContainer: false, + } as Reviewer; + const result = reviewerFilter(reviewer); + expect(result).toBe(true); + }); + + it('should return true if reviewer has a vote and is a container', () => { + const reviewer = { + voteStatus: PullRequestVoteStatus.Approved, + isContainer: true, + } as Reviewer; + const result = reviewerFilter(reviewer); + expect(result).toBe(false); + }); +}); + +describe('arrayExtract', () => { + it('should extract numbers greater than 3', () => { + const numbers = [1, 2, 3, 4, 5, 6]; + const numberFilter = (num: number): boolean => num > 3; + const extractedNumbers = arrayExtract(numbers, numberFilter); + expect(numbers).toEqual([1, 2, 3]); + expect(extractedNumbers).toEqual([4, 5, 6]); + }); + + it('should extract even numbers', () => { + const numbers = [1, 2, 3, 4, 5, 6]; + const numberFilter = (num: number): boolean => num % 2 === 0; + const extractedNumbers = arrayExtract(numbers, numberFilter); + expect(numbers).toEqual([1, 3, 5]); + expect(extractedNumbers).toEqual([2, 4, 6]); + }); +}); + +describe('getPullRequestGroups', () => { + it('should create groups of pull requests based on the provided configs', () => { + const userEmail = 'user1@backstage.com'; + const userEmail2 = 'user2@backstage.com'; + + const pullRequests = [ + { + pullRequestId: 1, + createdBy: { uniqueName: userEmail }, + } as DashboardPullRequest, + { + pullRequestId: 2, + createdBy: { uniqueName: userEmail }, + } as DashboardPullRequest, + { + pullRequestId: 3, + createdBy: { uniqueName: userEmail2 }, + } as DashboardPullRequest, + { + pullRequestId: 4, + createdBy: { uniqueName: userEmail2 }, + } as DashboardPullRequest, + ]; + + const configs = [ + { title: 'Created by me', filter: getCreatedByUserFilter(userEmail) }, + { title: 'Other PRs', filter: (_: unknown) => true, simplified: true }, + ]; + + const result = getPullRequestGroups(pullRequests, configs); + + expect(result.length).toBe(2); + + const group1 = result[0]; + expect(group1.title).toBe('Created by me'); + expect(group1.simplified).toBeFalsy(); + expect(group1.pullRequests.length).toBe(2); + expect(group1.pullRequests).toContainEqual( + expect.objectContaining({ pullRequestId: 1 }), + ); + expect(group1.pullRequests).toContainEqual( + expect.objectContaining({ pullRequestId: 2 }), + ); + + const group2 = result[1]; + expect(group2.title).toBe('Other PRs'); + expect(group2.simplified).toBe(true); + expect(group2.pullRequests.length).toBe(2); + expect(group2.pullRequests).toContainEqual( + expect.objectContaining({ pullRequestId: 3 }), + ); + expect(group2.pullRequests).toContainEqual( + expect.objectContaining({ pullRequestId: 4 }), + ); + }); +}); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.ts b/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.ts new file mode 100644 index 0000000000..c9cf866010 --- /dev/null +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/utils.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DashboardPullRequest, + PullRequestVoteStatus, + Reviewer, +} from '@backstage/plugin-azure-devops-common'; +import { + PullRequestFilter, + PullRequestGroup, + PullRequestGroupConfig, +} from './types'; + +/** + * Creates a filter that matches pull requests created by `userEmail`. + * @param userEmail an email to filter by. + * @returns a filter for pull requests created by `userEmail`. + */ +export function getCreatedByUserFilter( + userEmail: string | undefined, +): PullRequestFilter { + return (pullRequest: DashboardPullRequest): boolean => + pullRequest.createdBy?.uniqueName?.toLocaleLowerCase() === + userEmail?.toLocaleLowerCase(); +} + +/** + * Filters a reviewer based on vote status and if the reviewer is required. + * @param reviewer a reviewer to filter. + * @returns whether or not to filter the `reviewer`. + */ +export function reviewerFilter(reviewer: Reviewer): boolean { + return reviewer.voteStatus === PullRequestVoteStatus.NoVote + ? !!reviewer.isRequired + : !reviewer.isContainer; +} + +/** + * Removes values from the provided array and returns them. + * @param arr the array to extract values from. + * @param filter a filter used to extract values from the provided array. + * @returns the values that were extracted from the array. + * + * @example + * ```ts + * const numbers = [1, 2, 3, 4, 5, 6]; + * const numberFilter = (num: number): boolean => num > 3; + * const extractedNumbers = arrayExtract(numbers, numberFilter); + * console.log(numbers); // [1, 2, 3] + * console.log(extractedNumbers); // [4, 5, 6] + * ``` + * + * @example + * ```ts + * const numbers = [1, 2, 3, 4, 5, 6]; + * const numberFilter = (num: number): boolean => num % 2 === 0; + * const extractedNumbers = arrayExtract(numbers, numberFilter); + * console.log(numbers); // [1, 3, 5] + * console.log(extractedNumbers); // [2, 4, 6] + * ``` + */ +export function arrayExtract(arr: T[], filter: (value: T) => unknown): T[] { + const extractedValues: T[] = []; + + for (let i = 0; i - extractedValues.length < arr.length; i++) { + const offsetIndex = i - extractedValues.length; + + const value = arr[offsetIndex]; + + if (filter(value)) { + arr.splice(offsetIndex, 1); + extractedValues.push(value); + } + } + + return extractedValues; +} + +/** + * Creates groups of pull requests based on a list of `PullRequestGroupConfig`. + * @param pullRequests all pull requests to be split up into groups. + * @param configs the config used for splitting up the pull request groups. + * @returns a list of pull request groups. + */ +export function getPullRequestGroups( + pullRequests: DashboardPullRequest[], + configs: PullRequestGroupConfig[], +): PullRequestGroup[] { + const remainingPullRequests: DashboardPullRequest[] = [...pullRequests]; + const pullRequestGroups: PullRequestGroup[] = []; + + configs.forEach(({ title, filter: configFilter, simplified }) => { + const groupPullRequests = arrayExtract(remainingPullRequests, configFilter); + + pullRequestGroups.push({ + title, + pullRequests: groupPullRequests, + simplified, + }); + }); + + return pullRequestGroups; +} diff --git a/plugins/azure-devops/src/hooks/index.ts b/plugins/azure-devops/src/hooks/index.ts new file mode 100644 index 0000000000..44b94f3f4b --- /dev/null +++ b/plugins/azure-devops/src/hooks/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './useAllTeams'; +export * from './useDashboardPullRequests'; +export * from './useProjectRepoFromEntity'; +export * from './usePullRequests'; +export * from './useRepoBuilds'; +export * from './useUserEmail'; diff --git a/plugins/azure-devops/src/hooks/useAllTeams.ts b/plugins/azure-devops/src/hooks/useAllTeams.ts new file mode 100644 index 0000000000..d32aa5e136 --- /dev/null +++ b/plugins/azure-devops/src/hooks/useAllTeams.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Team } from '@backstage/plugin-azure-devops-common'; +import { azureDevOpsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import { useAsync } from 'react-use'; + +export function useAllTeams(): { + teams?: Team[]; + loading: boolean; + error?: Error; +} { + const api = useApi(azureDevOpsApiRef); + + const { + value: teams, + loading, + error, + } = useAsync(() => { + return api.getAllTeams(); + }, [api]); + + return { + teams, + loading, + error, + }; +} diff --git a/plugins/azure-devops/src/hooks/useDashboardPullRequests.ts b/plugins/azure-devops/src/hooks/useDashboardPullRequests.ts new file mode 100644 index 0000000000..1b2333ba1c --- /dev/null +++ b/plugins/azure-devops/src/hooks/useDashboardPullRequests.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { useAsyncRetry, useInterval } from 'react-use'; + +import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common'; +import { azureDevOpsApiRef } from '../api'; +import { useCallback } from 'react'; + +const POLLING_INTERVAL = 10000; + +export function useDashboardPullRequests( + project?: string, + pollingInterval: number = POLLING_INTERVAL, +): { + pullRequests?: DashboardPullRequest[]; + loading: boolean; + error?: Error; +} { + const api = useApi(azureDevOpsApiRef); + const errorApi = useApi(errorApiRef); + + const getDashboardPullRequests = useCallback(async (): Promise< + DashboardPullRequest[] + > => { + if (!project) { + return Promise.reject(new Error('Missing project name')); + } + + try { + return await api.getDashboardPullRequests(project); + } catch (error) { + if (error instanceof Error) { + errorApi.post(error); + } + + return Promise.reject(error); + } + }, [project, api, errorApi]); + + const { + value: pullRequests, + loading, + error, + retry, + } = useAsyncRetry( + () => getDashboardPullRequests(), + [getDashboardPullRequests], + ); + + useInterval(() => retry(), pollingInterval); + + return { + pullRequests, + loading, + error, + }; +} diff --git a/plugins/azure-devops/src/hooks/usePullRequests.ts b/plugins/azure-devops/src/hooks/usePullRequests.ts new file mode 100644 index 0000000000..178bf0495f --- /dev/null +++ b/plugins/azure-devops/src/hooks/usePullRequests.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + PullRequest, + PullRequestOptions, + PullRequestStatus, +} from '@backstage/plugin-azure-devops-common'; + +import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants'; +import { Entity } from '@backstage/catalog-model'; +import { azureDevOpsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import { useAsync } from 'react-use'; +import { useProjectRepoFromEntity } from './useProjectRepoFromEntity'; + +export function usePullRequests( + entity: Entity, + defaultLimit?: number, + requestedStatus?: PullRequestStatus, +): { + items?: PullRequest[]; + loading: boolean; + error?: Error; +} { + const top = defaultLimit ?? AZURE_DEVOPS_DEFAULT_TOP; + const status = requestedStatus ?? PullRequestStatus.Active; + const options: PullRequestOptions = { + top, + status, + }; + + const api = useApi(azureDevOpsApiRef); + const { project, repo } = useProjectRepoFromEntity(entity); + + const { value, loading, error } = useAsync(() => { + return api.getPullRequests(project, repo, options); + }, [api, project, repo, top, status]); + + return { + items: value?.items, + loading, + error, + }; +} diff --git a/plugins/azure-devops/src/hooks/useUserEmail.ts b/plugins/azure-devops/src/hooks/useUserEmail.ts new file mode 100644 index 0000000000..4655815297 --- /dev/null +++ b/plugins/azure-devops/src/hooks/useUserEmail.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; + +export function useUserEmail(): string | undefined { + const identityApi = useApi(identityApiRef); + return identityApi.getProfile().email; +} diff --git a/plugins/azure-devops/src/index.ts b/plugins/azure-devops/src/index.ts index 71d3b654fe..c9b804bc8d 100644 --- a/plugins/azure-devops/src/index.ts +++ b/plugins/azure-devops/src/index.ts @@ -13,8 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { azureDevOpsPlugin, EntityAzurePipelinesContent, + EntityAzurePullRequestsContent, isAzureDevOpsAvailable, + AzurePullRequestsPage, } from './plugin'; + +export { AzurePullRequestsIcon } from './components/AzurePullRequestsIcon'; diff --git a/plugins/azure-devops/src/plugin.ts b/plugins/azure-devops/src/plugin.ts index ab6d816460..003c7b7eee 100644 --- a/plugins/azure-devops/src/plugin.ts +++ b/plugins/azure-devops/src/plugin.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +import { + azurePipelinesEntityContentRouteRef, + azurePullRequestDashboardRouteRef, + azurePullRequestsEntityContentRouteRef, +} from './routes'; import { createApiFactory, createPlugin, @@ -26,7 +31,6 @@ import { AZURE_DEVOPS_ANNOTATION } from './constants'; import { AzureDevOpsClient } from './api/AzureDevOpsClient'; import { Entity } from '@backstage/catalog-model'; import { azureDevOpsApiRef } from './api/AzureDevOpsApi'; -import { azureDevOpsRouteRef } from './routes'; export const isAzureDevOpsAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[AZURE_DEVOPS_ANNOTATION]); @@ -41,11 +45,17 @@ export const azureDevOpsPlugin = createPlugin({ new AzureDevOpsClient({ discoveryApi, identityApi }), }), ], - routes: { - entityContent: azureDevOpsRouteRef, - }, }); +export const AzurePullRequestsPage = azureDevOpsPlugin.provide( + createRoutableExtension({ + name: 'AzurePullRequestsPage', + component: () => + import('./components/PullRequestsPage').then(m => m.PullRequestsPage), + mountPoint: azurePullRequestDashboardRouteRef, + }), +); + export const EntityAzurePipelinesContent = azureDevOpsPlugin.provide( createRoutableExtension({ name: 'EntityAzurePipelinesContent', @@ -53,6 +63,17 @@ export const EntityAzurePipelinesContent = azureDevOpsPlugin.provide( import('./components/EntityPageAzurePipelines').then( m => m.EntityPageAzurePipelines, ), - mountPoint: azureDevOpsRouteRef, + mountPoint: azurePipelinesEntityContentRouteRef, + }), +); + +export const EntityAzurePullRequestsContent = azureDevOpsPlugin.provide( + createRoutableExtension({ + name: 'EntityAzurePullRequestsContent', + component: () => + import('./components/EntityPageAzurePullRequests').then( + m => m.EntityPageAzurePullRequests, + ), + mountPoint: azurePullRequestsEntityContentRouteRef, }), ); diff --git a/plugins/azure-devops/src/routes.ts b/plugins/azure-devops/src/routes.ts index 81f4b48fda..0d20c12beb 100644 --- a/plugins/azure-devops/src/routes.ts +++ b/plugins/azure-devops/src/routes.ts @@ -13,8 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { createRouteRef } from '@backstage/core-plugin-api'; -export const azureDevOpsRouteRef = createRouteRef({ - title: 'azure-devops', +export const azurePullRequestDashboardRouteRef = createRouteRef({ + id: 'azure-pull-request-dashboard', +}); + +export const azurePipelinesEntityContentRouteRef = createRouteRef({ + id: 'azure-pipelines-entity-content', +}); + +export const azurePullRequestsEntityContentRouteRef = createRouteRef({ + id: 'azure-pull-requests-entity-content', }); diff --git a/plugins/azure-devops/src/utils/getDurationFromDates.test.ts b/plugins/azure-devops/src/utils/getDurationFromDates.test.ts new file mode 100644 index 0000000000..fac0e917d0 --- /dev/null +++ b/plugins/azure-devops/src/utils/getDurationFromDates.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getDurationFromDates } from './getDurationFromDates'; + +describe('getDurationFromDates', () => { + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2021-10-15T09:45:25.0000000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('getDurationFromDates with valid startTime and valid finishTime', () => { + it('should return empty result', () => { + const startTime = '2021-10-15T09:30:00.0000000Z'; + const finishTime = '2021-10-15T11:00:00.0000000Z'; + + const result = getDurationFromDates(startTime, finishTime); + + expect(result).toEqual('1h, 30m'); + }); + }); + + describe('getDurationFromDates with valid startTime and undefined finishTime', () => { + it('should return empty result', () => { + const startTime = '2021-10-15T09:30:00.0000000Z'; + + const result = getDurationFromDates(startTime, undefined); + + expect(result).toEqual('15m, 25s'); + }); + }); + + describe('getDurationFromDates with undefined startTime and valid finishTime', () => { + it('should return empty result', () => { + const finishTime = '2021-10-15T11:00:00.0000000Z'; + + const result = getDurationFromDates(undefined, finishTime); + + expect(result).toEqual(''); + }); + }); + + describe('getDurationFromDates with undefined startTime and undefined finishTime', () => { + it('should return empty result', () => { + const result = getDurationFromDates(undefined, undefined); + + expect(result).toEqual(''); + }); + }); +}); diff --git a/plugins/azure-devops/src/utils/getDurationFromDates.ts b/plugins/azure-devops/src/utils/getDurationFromDates.ts new file mode 100644 index 0000000000..789849f975 --- /dev/null +++ b/plugins/azure-devops/src/utils/getDurationFromDates.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DateTime, Interval } from 'luxon'; +import humanizeDuration from 'humanize-duration'; + +export const getDurationFromDates = ( + startTime?: string, + finishTime?: string, +): string => { + if (!startTime || (!startTime && !finishTime)) { + return ''; + } + + const start = DateTime.fromISO(startTime); + const finish = finishTime ? DateTime.fromISO(finishTime) : DateTime.now(); + + const formatted = Interval.fromDateTimes(start, finish) + .toDuration() + .valueOf(); + + const shortEnglishHumanizer = humanizeDuration.humanizer({ + language: 'shortEn', + languages: { + shortEn: { + y: () => 'y', + mo: () => 'mo', + w: () => 'w', + d: () => 'd', + h: () => 'h', + m: () => 'm', + s: () => 's', + ms: () => 'ms', + }, + }, + }); + + return shortEnglishHumanizer(formatted, { + largest: 2, + round: true, + spacer: '', + }); +}; diff --git a/plugins/badges-backend/CHANGELOG.md b/plugins/badges-backend/CHANGELOG.md index 5cd063d960..e1cdfa1490 100644 --- a/plugins/badges-backend/CHANGELOG.md +++ b/plugins/badges-backend/CHANGELOG.md @@ -1,5 +1,35 @@ # @backstage/plugin-badges-backend +## 0.1.13 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/backend-common@0.9.12 + +## 0.1.12 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/backend-common@0.9.11 + ## 0.1.11 ### Patch Changes diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 51a181612a..c481866a0a 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-badges-backend", "description": "A Backstage backend plugin that generates README badges for your entities", - "version": "0.1.11", + "version": "0.1.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,22 +31,21 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/errors": "^0.1.3", + "@backstage/errors": "^0.1.5", "@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.8.0", + "@backstage/cli": "^0.10.0", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/badges-backend/src/run.ts b/plugins/badges-backend/src/run.ts index addfdfd6d7..53d4e4334a 100644 --- a/plugins/badges-backend/src/run.ts +++ b/plugins/badges-backend/src/run.ts @@ -18,7 +18,7 @@ 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 port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 51ad1e7e11..286a2335f1 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -73,7 +73,7 @@ describe('createRouter', () => { config = new ConfigReader({ backend: { baseUrl: 'http://127.0.0.1', - listen: { port: 7000 }, + listen: { port: 7007 }, }, }); discovery = SingleHostDiscovery.fromConfig(config); diff --git a/plugins/badges/CHANGELOG.md b/plugins/badges/CHANGELOG.md index 3a6ed61717..449352f87b 100644 --- a/plugins/badges/CHANGELOG.md +++ b/plugins/badges/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-badges +## 0.2.15 + +### Patch Changes + +- 4149d74c10: Fix the path that the Badges client uses towards the `plugin-badges-backend` +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.2.14 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.13 ### Patch Changes diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 0c792808bc..7c478c0a26 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-badges", "description": "A Backstage plugin that generates README badges for your entities", - "version": "0.2.13", + "version": "0.2.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,12 +27,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/errors": "^0.1.3", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/errors": "^0.1.5", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -42,10 +42,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index d81b042ce5..8ad5d313e4 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -17,7 +17,6 @@ import { generatePath } from 'react-router'; 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'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; @@ -53,7 +52,7 @@ export class BadgesClient implements BadgesApi { private async getEntityBadgeSpecsUrl(entity: Entity): Promise { const routeParams = this.getEntityRouteParams(entity); - const path = generatePath(entityRoute.path, routeParams); + const path = generatePath(`:namespace/:kind/:name`, routeParams); return `${await this.discoveryApi.getBaseUrl( 'badges', )}/entity/${path}/badge-specs`; diff --git a/plugins/badges/src/components/EntityBadgesDialog.test.tsx b/plugins/badges/src/components/EntityBadgesDialog.test.tsx index 1f5ee94e04..ea44eb20fc 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.test.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.test.tsx @@ -16,12 +16,11 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; -import { renderWithEffects } from '@backstage/test-utils'; +import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; import { BadgesApi, badgesApiRef } from '../api'; import { EntityBadgesDialog } from './EntityBadgesDialog'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api'; describe('EntityBadgesDialog', () => { @@ -42,16 +41,16 @@ describe('EntityBadgesDialog', () => { const mockEntity = { metadata: { name: 'mock' } } as Entity; const rendered = await renderWithEffects( - - , + , ); await expect( diff --git a/plugins/bazaar-backend/CHANGELOG.md b/plugins/bazaar-backend/CHANGELOG.md new file mode 100644 index 0000000000..812ae8f5e3 --- /dev/null +++ b/plugins/bazaar-backend/CHANGELOG.md @@ -0,0 +1,31 @@ +# @backstage/plugin-bazaar-backend + +## 0.1.3 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/backend-common@0.9.11 + +## 0.1.2 + +### Patch Changes + +- f6ba309d9e: A Bazaar project has been extended with the following fields: size, start date (optional), end date (optional) and a responsible person. +- Updated dependencies + - @backstage/backend-common@0.9.10 + - @backstage/backend-test-utils@0.1.9 diff --git a/plugins/bazaar-backend/migrations/20211020073922_add_columns.js b/plugins/bazaar-backend/migrations/20211020073922_add_columns.js index d4cb87bd82..ea3a89ea72 100644 --- a/plugins/bazaar-backend/migrations/20211020073922_add_columns.js +++ b/plugins/bazaar-backend/migrations/20211020073922_add_columns.js @@ -29,6 +29,7 @@ exports.up = async function up(knex) { .comment('Optional end date of the project (ISO 8601 format)'); table .text('responsible') + .defaultTo('') .notNullable() .comment('Contact person of the project'); }); diff --git a/plugins/bazaar-backend/package.json b/plugins/bazaar-backend/package.json index a9ac0153b8..aec96bb021 100644 --- a/plugins/bazaar-backend/package.json +++ b/plugins/bazaar-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-bazaar-backend", - "version": "0.1.1", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/backend-test-utils": "^0.1.8", + "@backstage/backend-common": "^0.9.12", + "@backstage/backend-test-utils": "^0.1.10", "@backstage/config": "^0.1.5", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -31,7 +31,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0" + "@backstage/cli": "^0.10.0" }, "files": [ "dist", diff --git a/plugins/bazaar-backend/src/run.ts b/plugins/bazaar-backend/src/run.ts index 54d2716290..0a3ed2b7f0 100644 --- a/plugins/bazaar-backend/src/run.ts +++ b/plugins/bazaar-backend/src/run.ts @@ -18,7 +18,7 @@ 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 port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/bazaar/CHANGELOG.md b/plugins/bazaar/CHANGELOG.md index 731f62b9e9..2a21310347 100644 --- a/plugins/bazaar/CHANGELOG.md +++ b/plugins/bazaar/CHANGELOG.md @@ -1,5 +1,28 @@ # @backstage/plugin-bazaar +## 0.1.5 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/cli@0.10.0 + - @backstage/core-plugin-api@0.2.2 + +## 0.1.4 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- f6ba309d9e: A Bazaar project has been extended with the following fields: size, start date (optional), end date (optional) and a responsible person. +- Updated dependencies + - @backstage/plugin-catalog@0.7.3 + - @backstage/cli@0.9.0 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.3 ### Patch Changes diff --git a/plugins/bazaar/package.json b/plugins/bazaar/package.json index 094741bf36..2defba5cca 100644 --- a/plugins/bazaar/package.json +++ b/plugins/bazaar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-bazaar", - "version": "0.1.3", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,18 +21,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/cli": "^0.8.2", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog": "^0.7.2", - "@backstage/plugin-catalog-react": "^0.6.3", + "@backstage/catalog-model": "^0.9.7", + "@backstage/cli": "^0.10.0", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog": "^0.7.3", + "@backstage/plugin-catalog-react": "^0.6.4", + "@date-io/luxon": "1.x", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@material-ui/pickers": "^3.3.10", "@testing-library/jest-dom": "^5.10.1", - "@date-io/luxon": "1.x", "luxon": "^2.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -41,8 +41,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/dev-utils": "^0.2.12", + "@backstage/cli": "^0.10.0", + "@backstage/dev-utils": "^0.2.13", "@testing-library/jest-dom": "^5.10.1", "cross-fetch": "^3.0.6" }, diff --git a/plugins/bazaar/src/routes.ts b/plugins/bazaar/src/routes.ts index a3cc88d86f..9e4a8fb218 100644 --- a/plugins/bazaar/src/routes.ts +++ b/plugins/bazaar/src/routes.ts @@ -17,5 +17,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'bazaar', + id: 'bazaar', }); diff --git a/plugins/bitrise/CHANGELOG.md b/plugins/bitrise/CHANGELOG.md index 7e364ca294..05f12c9fdd 100644 --- a/plugins/bitrise/CHANGELOG.md +++ b/plugins/bitrise/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/plugin-bitrise +## 0.1.18 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.1.17 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.16 ### Patch Changes diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index 5ebfd453a2..b036610f0a 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-bitrise", "description": "A Backstage plugin that integrates towards Bitrise", - "version": "0.1.16", + "version": "0.1.18", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,11 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -39,17 +39,16 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/recharts": "^1.8.15", - "cross-fetch": "^3.0.6", "msw": "^0.35.0" }, "files": [ diff --git a/plugins/bitrise/src/components/BitriseBuildsTableComponent/BitriseBuildsTableComponent.test.tsx b/plugins/bitrise/src/components/BitriseBuildsTableComponent/BitriseBuildsTableComponent.test.tsx index a206e28de6..82faa3e39c 100644 --- a/plugins/bitrise/src/components/BitriseBuildsTableComponent/BitriseBuildsTableComponent.test.tsx +++ b/plugins/bitrise/src/components/BitriseBuildsTableComponent/BitriseBuildsTableComponent.test.tsx @@ -21,14 +21,11 @@ import { setupServer } from 'msw/node'; import { setupRequestMockHandlers, renderInTestApp, + TestApiRegistry, } from '@backstage/test-utils'; import { useBitriseBuilds } from '../../hooks/useBitriseBuilds'; import { BitriseBuildsTable } from './BitriseBuildsTableComponent'; -import { - ApiProvider, - ApiRegistry, - UrlPatternDiscovery, -} from '@backstage/core-app-api'; +import { ApiProvider, UrlPatternDiscovery } from '@backstage/core-app-api'; jest.mock('../../hooks/useBitriseBuilds', () => ({ useBitriseBuilds: jest.fn(), @@ -40,10 +37,13 @@ describe('BitriseBuildsFetchComponent', () => { setupRequestMockHandlers(server); const mockBaseUrl = 'http://backstage:9191'; const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); - let apis: ApiRegistry; + let apis: TestApiRegistry; beforeEach(() => { - apis = ApiRegistry.with(bitriseApiRef, new BitriseClientApi(discoveryApi)); + apis = TestApiRegistry.from([ + bitriseApiRef, + new BitriseClientApi(discoveryApi), + ]); }); it('should display `no records` message if there are no builds', async () => { diff --git a/plugins/catalog-backend-module-ldap/CHANGELOG.md b/plugins/catalog-backend-module-ldap/CHANGELOG.md index a776f3f329..b8e48924dc 100644 --- a/plugins/catalog-backend-module-ldap/CHANGELOG.md +++ b/plugins/catalog-backend-module-ldap/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-catalog-backend-module-ldap +## 0.3.8 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-backend@0.19.0 + +## 0.3.7 + +### Patch Changes + +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/plugin-catalog-backend@0.18.0 + ## 0.3.6 ### Patch Changes diff --git a/plugins/catalog-backend-module-ldap/package.json b/plugins/catalog-backend-module-ldap/package.json index a8c3b71345..2817a0e209 100644 --- a/plugins/catalog-backend-module-ldap/package.json +++ b/plugins/catalog-backend-module-ldap/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-backend-module-ldap", "description": "A Backstage catalog backend modules that helps integrate towards LDAP", - "version": "0.3.6", + "version": "0.3.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/errors": "^0.1.4", - "@backstage/plugin-catalog-backend": "^0.17.2", + "@backstage/errors": "^0.1.5", + "@backstage/plugin-catalog-backend": "^0.19.0", "@backstage/types": "^0.1.1", "@types/ldapjs": "^2.2.0", "ldapjs": "^2.2.0", @@ -40,7 +40,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.1", + "@backstage/cli": "^0.10.0", "@types/lodash": "^4.14.151" }, "files": [ diff --git a/plugins/catalog-backend-module-msgraph/CHANGELOG.md b/plugins/catalog-backend-module-msgraph/CHANGELOG.md index ee8aa49065..3db08d02fc 100644 --- a/plugins/catalog-backend-module-msgraph/CHANGELOG.md +++ b/plugins/catalog-backend-module-msgraph/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-catalog-backend-module-msgraph +## 0.2.11 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/plugin-catalog-backend@0.19.0 + +## 0.2.10 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-backend@0.18.0 + ## 0.2.9 ### Patch Changes diff --git a/plugins/catalog-backend-module-msgraph/api-report.md b/plugins/catalog-backend-module-msgraph/api-report.md index 1357659739..e1a837d0c1 100644 --- a/plugins/catalog-backend-module-msgraph/api-report.md +++ b/plugins/catalog-backend-module-msgraph/api-report.md @@ -13,6 +13,7 @@ import { LocationSpec } from '@backstage/catalog-model'; import { Logger as Logger_2 } from 'winston'; import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; import * as msal from '@azure/msal-node'; +import { Response as Response_2 } from 'node-fetch'; import { UserEntity } from '@backstage/catalog-model'; // Warning: (ae-missing-release-tag) "defaultGroupTransformer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -88,11 +89,11 @@ export class MicrosoftGraphClient { // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-catalog-backend-module-msgraph" does not have an export "ODataQuery" getUsers(query?: ODataQuery): AsyncIterable; // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-catalog-backend-module-msgraph" does not have an export "ODataQuery" - requestApi(path: string, query?: ODataQuery): Promise; + requestApi(path: string, query?: ODataQuery): Promise; // Warning: (ae-forgotten-export) The symbol "ODataQuery" needs to be exported by the entry point index.d.ts // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-catalog-backend-module-msgraph" does not have an export "ODataQuery" requestCollection(path: string, query?: ODataQuery): AsyncIterable; - requestRaw(url: string): Promise; + requestRaw(url: string): Promise; } // Warning: (ae-missing-release-tag) "MicrosoftGraphOrgEntityProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/catalog-backend-module-msgraph/package.json b/plugins/catalog-backend-module-msgraph/package.json index b3c9494f78..8e9b99931c 100644 --- a/plugins/catalog-backend-module-msgraph/package.json +++ b/plugins/catalog-backend-module-msgraph/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-backend-module-msgraph", "description": "A Backstage catalog backend modules that helps integrate towards Microsoft Graph", - "version": "0.2.9", + "version": "0.2.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,20 +30,21 @@ }, "dependencies": { "@azure/msal-node": "^1.1.0", - "@backstage/catalog-model": "^0.9.5", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/plugin-catalog-backend": "^0.17.3", + "@backstage/plugin-catalog-backend": "^0.19.0", "@microsoft/microsoft-graph-types": "^2.6.0", - "cross-fetch": "^3.0.6", + "@types/node-fetch": "^2.5.12", "lodash": "^4.17.21", + "node-fetch": "^2.6.1", "p-limit": "^3.0.2", "winston": "^3.2.1", "qs": "^6.9.4" }, "devDependencies": { - "@backstage/backend-common": "^0.9.9", - "@backstage/cli": "^0.8.2", - "@backstage/test-utils": "^0.1.21", + "@backstage/backend-common": "^0.9.12", + "@backstage/cli": "^0.10.0", + "@backstage/test-utils": "^0.1.23", "@types/lodash": "^4.14.151", "msw": "^0.35.0" }, diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts index 83b4371c0d..63f256afbd 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts @@ -16,7 +16,7 @@ import * as msal from '@azure/msal-node'; import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; -import fetch from 'cross-fetch'; +import fetch, { Response } from 'node-fetch'; import qs from 'qs'; import { MicrosoftGraphProviderConfig } from './config'; diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index abb50a345c..93e41cfddc 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,123 @@ # @backstage/plugin-catalog-backend +## 0.19.0 + +### Minor Changes + +- 905dd952ac: **BREAKING** `DefaultCatalogCollator` has a new required option `tokenManager`. See the create-app changelog for how to create a `tokenManager` and add it to the `PluginEnvironment`. It can then be passed to the collator in `createPlugin`: + + ```diff + // packages/backend/src/plugins/search.ts + + ... + export default async function createPlugin({ + ... + + tokenManager, + }: PluginEnvironment) { + ... + + indexBuilder.addCollator({ + defaultRefreshIntervalSeconds: 600, + collator: DefaultCatalogCollator.fromConfig(config, { + discovery, + + tokenManager, + }), + }); + + ... + } + ``` + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/backend-common@0.9.12 + +## 0.18.0 + +### Minor Changes + +- 7f82ce9f51: **BREAKING** EntitiesSearchFilter fields have changed. + + EntitiesSearchFilter now has only two fields: `key` and `value`. The `matchValueIn` and `matchValueExists` fields are no longer are supported. Previous filters written using the `matchValueIn` and `matchValueExists` fields can be rewritten as follows: + + Filtering by existence of key only: + + ```diff + filter: { + { + key: 'abc', + - matchValueExists: true, + }, + } + ``` + + Filtering by key and values: + + ```diff + filter: { + { + key: 'abc', + - matchValueExists: true, + - matchValueIn: ['xyz'], + + values: ['xyz'], + }, + } + ``` + + Negation of filters can now be achieved through a `not` object: + + ``` + filter: { + not: { + key: 'abc', + values: ['xyz'], + }, + } + ``` + +### Patch Changes + +- 740f958290: Providing an empty values array in an EntityFilter will now return no matches. +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- eddb82ab7c: Index User entities by displayName to be able to search by full name. Added displayName (if present) to the 'text' field in the indexed document. +- 563b039f0b: Added Azure DevOps discovery processor +- 8866b62f3d: Detect a duplicate entities when adding locations through dry run +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/backend-common@0.9.11 + +## 0.17.4 + +### Patch Changes + +- 5d2a7303bd: This fixes a bug where locations couldn't be added unless the processing engine is started. + It's now possible to run the catalog backend without starting the processing engine and still allowing locations registrations. + + This is done by refactor the `EntityProvider.connect` to happen outside the engine. + +- 06934f2f52: Adjust entity query construction to ensure sub-queries are always isolated from one another. +- b90fc74d70: adds getDefaultProcessor method to CatalogBuilder +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + ## 0.17.3 ### Patch Changes diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 6660912dda..43eb8d61b7 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -31,6 +31,7 @@ import { ResourceEntityV1alpha1 } from '@backstage/catalog-model'; import { Router } from 'express'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { ScmIntegrations } from '@backstage/integration'; +import { TokenManager } from '@backstage/backend-common'; import { UrlReader } from '@backstage/backend-common'; import { Validators } from '@backstage/catalog-model'; @@ -174,6 +175,26 @@ export class AwsS3DiscoveryProcessor implements CatalogProcessor { ): Promise; } +// Warning: (ae-missing-release-tag) "AzureDevOpsDiscoveryProcessor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor { + constructor(options: { integrations: ScmIntegrations; logger: Logger_2 }); + // (undocumented) + static fromConfig( + config: Config, + options: { + logger: Logger_2; + }, + ): AzureDevOpsDiscoveryProcessor; + // (undocumented) + readLocation( + location: LocationSpec, + _optional: boolean, + emit: CatalogProcessorEmit, + ): Promise; +} + // Warning: (ae-missing-release-tag) "BitbucketDiscoveryProcessor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -736,8 +757,10 @@ export class DefaultCatalogCollator implements DocumentCollator { locationTemplate, filter, catalogClient, + tokenManager, }: { discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; locationTemplate?: string; filter?: CatalogEntitiesRequest['filter']; catalogClient?: CatalogApi; @@ -760,12 +783,15 @@ export class DefaultCatalogCollator implements DocumentCollator { _config: Config, options: { discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; filter?: CatalogEntitiesRequest['filter']; }, ): DefaultCatalogCollator; // (undocumented) protected locationTemplate: string; // (undocumented) + protected tokenManager: TokenManager; + // (undocumented) readonly type: string; } @@ -846,8 +872,7 @@ export type EntitiesResponse = { // @public export type EntitiesSearchFilter = { key: string; - matchValueIn?: string[]; - matchValueExists?: boolean; + values?: string[]; }; // Warning: (ae-missing-release-tag) "entity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -877,6 +902,9 @@ export type EntityFilter = | { anyOf: EntityFilter[]; } + | { + not: EntityFilter; + } | EntitiesSearchFilter; // Warning: (ae-missing-release-tag) "EntityPagination" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1546,9 +1574,9 @@ export class UrlReaderProcessor implements CatalogProcessor { // Warnings were encountered during analysis: // -// src/catalog/types.d.ts:97:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// src/catalog/types.d.ts:98:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// src/catalog/types.d.ts:99:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// src/catalog/types.d.ts:94:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// src/catalog/types.d.ts:95:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// src/catalog/types.d.ts:96:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters // src/ingestion/processors/GithubMultiOrgReaderProcessor.d.ts:23:9 - (ae-forgotten-export) The symbol "GithubMultiOrgConfig" needs to be exported by the entry point index.d.ts // src/ingestion/types.d.ts:8:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/legacy/database/types.d.ts:98:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index f13ff2d66c..694db42877 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-backend", "description": "The Backstage backend plugin that provides the Backstage catalog", - "version": "0.17.3", + "version": "0.19.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.9", - "@backstage/catalog-client": "^0.5.1", - "@backstage/catalog-model": "^0.9.6", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", + "@backstage/errors": "^0.1.5", + "@backstage/integration": "^0.6.10", "@backstage/search-common": "^0.2.1", "@backstage/types": "^0.1.1", "@octokit/graphql": "^4.5.8", @@ -43,7 +43,6 @@ "aws-sdk": "^2.840.0", "codeowners-utils": "^1.0.2", "core-js": "^3.6.5", - "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", "fast-json-stable-stringify": "^2.1.0", @@ -53,6 +52,7 @@ "knex": "^0.95.1", "lodash": "^4.17.21", "luxon": "^2.0.2", + "node-fetch": "^2.6.1", "p-limit": "^3.0.2", "prom-client": "^13.2.0", "uuid": "^8.0.0", @@ -62,9 +62,9 @@ "yup": "^0.32.9" }, "devDependencies": { - "@backstage/backend-test-utils": "^0.1.8", - "@backstage/cli": "^0.8.2", - "@backstage/test-utils": "^0.1.21", + "@backstage/backend-test-utils": "^0.1.10", + "@backstage/cli": "^0.10.0", + "@backstage/test-utils": "^0.1.23", "@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/types.ts b/plugins/catalog-backend/src/catalog/types.ts index df693bfd42..2a18783679 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -25,6 +25,7 @@ import { Entity, EntityRelationSpec } from '@backstage/catalog-model'; export type EntityFilter = | { allOf: EntityFilter[] } | { anyOf: EntityFilter[] } + | { not: EntityFilter } | EntitiesSearchFilter; /** @@ -50,16 +51,10 @@ export type EntitiesSearchFilter = { /** * Match on plain equality of values. * - * If undefined, this factor is not taken into account. Otherwise, match on - * values that are equal to any of the given array items. Matches are always - * case insensitive. + * Match on values that are equal to any of the given array items. Matches are + * always case insensitive. */ - matchValueIn?: string[]; - - /** - * Match on existence of key. - */ - matchValueExists?: boolean; + values?: string[]; }; export type PageInfo = diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.test.ts new file mode 100644 index 0000000000..ab053e8748 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.test.ts @@ -0,0 +1,277 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { LocationSpec } from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; +import { codeSearch } from './azure'; +import { + AzureDevOpsDiscoveryProcessor, + parseUrl, +} from './AzureDevOpsDiscoveryProcessor'; + +jest.mock('./azure'); +const mockCodeSearch = codeSearch as jest.MockedFunction; + +describe('AzureDevOpsDiscoveryProcessor', () => { + describe('parseUrl', () => { + it('parses well formed URLs', () => { + expect(parseUrl('https://dev.azure.com/my-org/my-proj')).toEqual({ + baseUrl: 'https://dev.azure.com', + org: 'my-org', + project: 'my-proj', + repo: '', + catalogPath: '/catalog-info.yaml', + }); + + expect( + parseUrl( + 'https://dev.azure.com/spotify/engineering/_git/backstage?path=/catalog.yaml', + ), + ).toEqual({ + baseUrl: 'https://dev.azure.com', + org: 'spotify', + project: 'engineering', + repo: 'backstage', + catalogPath: '/catalog.yaml', + }); + + expect( + parseUrl( + 'https://azuredevops.mycompany.com/spotify/engineering/_git/backstage?path=/src/*/catalog.yaml', + ), + ).toEqual({ + baseUrl: 'https://azuredevops.mycompany.com', + org: 'spotify', + project: 'engineering', + repo: 'backstage', + catalogPath: '/src/*/catalog.yaml', + }); + }); + + it('throws on incorrectly formed URLs', () => { + expect(() => parseUrl('https://dev.azure.com')).toThrow(); + expect(() => parseUrl('https://dev.azure.com//')).toThrow(); + expect(() => parseUrl('https://dev.azure.com//foo')).toThrow(); + }); + }); + + describe('reject unrelated entries', () => { + it('rejects unknown types', async () => { + const processor = AzureDevOpsDiscoveryProcessor.fromConfig( + new ConfigReader({ + integrations: { + azure: [{ host: 'dev.azure.com', token: 'blob' }], + }, + }), + { logger: getVoidLogger() }, + ); + const location: LocationSpec = { + type: 'not-azure-discovery', + target: 'https://dev.azure.com', + }; + await expect( + processor.readLocation(location, false, () => {}), + ).resolves.toBeFalsy(); + }); + }); + + it('rejects unknown targets', async () => { + const processor = AzureDevOpsDiscoveryProcessor.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { host: 'dev.azure.com', token: 'blob' }, + { host: 'azure.myorg.com', token: 'blob' }, + ], + }, + }), + { logger: getVoidLogger() }, + ); + const location: LocationSpec = { + type: 'azure-discovery', + target: 'https://not.azure.com/org/project', + }; + await expect( + processor.readLocation(location, false, () => {}), + ).rejects.toThrow( + /There is no Azure integration that matches https:\/\/not.azure.com\/org\/project. Please add a configuration entry for it under integrations.azure/, + ); + }); + + describe('handles repositories', () => { + const processor = AzureDevOpsDiscoveryProcessor.fromConfig( + new ConfigReader({ + integrations: { + github: [{ host: 'dev.azure.com', token: 'blob' }], + }, + }), + { logger: getVoidLogger() }, + ); + + beforeEach(() => { + mockCodeSearch.mockClear(); + }); + + it('output all locations found on from code search', async () => { + const location: LocationSpec = { + type: 'azure-discovery', + target: 'https://dev.azure.com/shopify/engineering', + }; + mockCodeSearch.mockResolvedValueOnce([ + { + fileName: 'catalog-info.yaml', + path: '/catalog-info.yaml', + repository: { + name: 'backstage', + }, + }, + { + fileName: 'catalog-info.yaml', + path: '/src/catalog-info.yaml', + repository: { + name: 'ios-app', + }, + }, + ]); + const emitter = jest.fn(); + + await processor.readLocation(location, false, emitter); + + expect(mockCodeSearch).toHaveBeenCalledWith( + { host: 'dev.azure.com' }, + 'shopify', + 'engineering', + '', + '/catalog-info.yaml', + ); + expect(emitter).toHaveBeenCalledTimes(2); + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: + 'https://dev.azure.com/shopify/engineering/_git/backstage?path=/catalog-info.yaml', + }, + optional: true, + }); + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: + 'https://dev.azure.com/shopify/engineering/_git/ios-app?path=/src/catalog-info.yaml', + }, + optional: true, + }); + }); + + it('output single locations from code search', async () => { + const location: LocationSpec = { + type: 'azure-discovery', + target: 'https://dev.azure.com/shopify/engineering/_git/backstage', + }; + mockCodeSearch.mockResolvedValueOnce([ + { + fileName: 'catalog-info.yaml', + path: '/catalog-info.yaml', + repository: { + name: 'backstage', + }, + }, + ]); + const emitter = jest.fn(); + + await processor.readLocation(location, false, emitter); + + expect(mockCodeSearch).toHaveBeenCalledWith( + { host: 'dev.azure.com' }, + 'shopify', + 'engineering', + 'backstage', + '/catalog-info.yaml', + ); + expect(emitter).toHaveBeenCalledTimes(1); + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: + 'https://dev.azure.com/shopify/engineering/_git/backstage?path=/catalog-info.yaml', + }, + optional: true, + }); + }); + + it('output single locations with different file name from code search', async () => { + const location: LocationSpec = { + type: 'azure-discovery', + target: + 'https://dev.azure.com/shopify/engineering?path=/src/*/catalog.yaml', + }; + mockCodeSearch.mockResolvedValueOnce([ + { + fileName: 'catalog.yaml', + path: '/src/main/catalog.yaml', + repository: { + name: 'backstage', + }, + }, + ]); + const emitter = jest.fn(); + + await processor.readLocation(location, false, emitter); + + expect(mockCodeSearch).toHaveBeenCalledWith( + { host: 'dev.azure.com' }, + 'shopify', + 'engineering', + '', + '/src/*/catalog.yaml', + ); + expect(emitter).toHaveBeenCalledTimes(1); + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: + 'https://dev.azure.com/shopify/engineering/_git/backstage?path=/src/main/catalog.yaml', + }, + optional: true, + }); + }); + + it('output nothing when code search does not find anything', async () => { + const location: LocationSpec = { + type: 'azure-discovery', + target: 'https://dev.azure.com/shopify/engineering/_git/backstage', + }; + mockCodeSearch.mockResolvedValueOnce([]); + const emitter = jest.fn(); + + await processor.readLocation(location, false, emitter); + + expect(mockCodeSearch).toHaveBeenCalledWith( + { host: 'dev.azure.com' }, + 'shopify', + 'engineering', + 'backstage', + '/catalog-info.yaml', + ); + expect(emitter).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.ts new file mode 100644 index 0000000000..58ee2155e9 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AzureDevOpsDiscoveryProcessor.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocationSpec } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import { Logger } from 'winston'; +import * as results from './results'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; +import { codeSearch } from './azure'; + +/** + * Extracts repositories out of an Azure DevOps org. + * + * The following will create locations for all projects which have a catalog-info.yaml + * on the default branch. The first is shorthand for the second. + * + * target: "https://dev.azure.com/org/project" + * or + * target: https://dev.azure.com/org/project?path=/catalog-info.yaml + * + * You may also explicitly specify a single repo: + * + * target: https://dev.azure.com/org/project/_git/repo + **/ +export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor { + private readonly integrations: ScmIntegrations; + private readonly logger: Logger; + + static fromConfig(config: Config, options: { logger: Logger }) { + const integrations = ScmIntegrations.fromConfig(config); + + return new AzureDevOpsDiscoveryProcessor({ + ...options, + integrations, + }); + } + + constructor(options: { integrations: ScmIntegrations; logger: Logger }) { + this.integrations = options.integrations; + this.logger = options.logger; + } + + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: CatalogProcessorEmit, + ): Promise { + if (location.type !== 'azure-discovery') { + return false; + } + + const azureConfig = this.integrations.azure.byUrl(location.target)?.config; + if (!azureConfig) { + throw new Error( + `There is no Azure integration that matches ${location.target}. Please add a configuration entry for it under integrations.azure`, + ); + } + + const { baseUrl, org, project, repo, catalogPath } = parseUrl( + location.target, + ); + this.logger.info( + `Reading Azure DevOps repositories from ${location.target}`, + ); + + const files = await codeSearch( + azureConfig, + org, + project, + repo, + catalogPath, + ); + + this.logger.debug( + `Found ${files.length} files in Azure DevOps from ${location.target}.`, + ); + + for (const file of files) { + emit( + results.location( + { + type: 'url', + target: `${baseUrl}/${org}/${project}/_git/${file.repository.name}?path=${file.path}`, + }, + // Not all locations may actually exist, since the user defined them as a wildcard pattern. + // Thus, we emit them as optional and let the downstream processor find them while not outputting + // an error if it couldn't. + true, + ), + ); + } + + return true; + } +} + +/** + * parseUrl extracts segments from the Azure DevOps URL. + **/ +export function parseUrl(urlString: string): { + baseUrl: string; + org: string; + project: string; + repo: string; + catalogPath: string; +} { + const url = new URL(urlString); + const path = url.pathname.substr(1).split('/'); + + const catalogPath = url.searchParams.get('path') || '/catalog-info.yaml'; + + if (path.length === 2 && path[0].length && path[1].length) { + return { + baseUrl: url.origin, + org: decodeURIComponent(path[0]), + project: decodeURIComponent(path[1]), + repo: '', + catalogPath, + }; + } else if ( + path.length === 4 && + path[0].length && + path[1].length && + path[2].length && + path[3].length + ) { + return { + baseUrl: url.origin, + org: decodeURIComponent(path[0]), + project: decodeURIComponent(path[1]), + repo: decodeURIComponent(path[3]), + catalogPath, + }; + } + + throw new Error(`Failed to parse ${urlString}`); +} diff --git a/plugins/catalog-backend/src/ingestion/processors/azure/azure.test.ts b/plugins/catalog-backend/src/ingestion/processors/azure/azure.test.ts new file mode 100644 index 0000000000..67cc120bab --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/azure/azure.test.ts @@ -0,0 +1,232 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { codeSearch, CodeSearchResponse } from './azure'; + +describe('azure', () => { + const server = setupServer(); + setupRequestMockHandlers(server); + + describe('codeSearch', () => { + it('returns empty when nothing is found', async () => { + const response: CodeSearchResponse = { count: 0, results: [] }; + + server.use( + rest.post( + `https://almsearch.dev.azure.com/shopify/engineering/_apis/search/codesearchresults`, + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Basic OkFCQw=='); + expect(req.body).toEqual({ + searchText: 'path:/catalog-info.yaml repo:*', + $skip: 0, + $top: 1000, + }); + return res(ctx.json(response)); + }, + ), + ); + + await expect( + codeSearch( + { host: 'dev.azure.com', token: 'ABC' }, + 'shopify', + 'engineering', + '', + '/catalog-info.yaml', + ), + ).resolves.toEqual([]); + }); + }); + + it('returns entries when request matches some files', async () => { + const response: CodeSearchResponse = { + count: 2, + results: [ + { + fileName: 'catalog-info.yaml', + path: '/catalog-info.yaml', + repository: { + name: 'backstage', + }, + }, + { + fileName: 'catalog-info.yaml', + path: '/catalog-info.yaml', + repository: { + name: 'ios-app', + }, + }, + ], + }; + + server.use( + rest.post( + `https://almsearch.dev.azure.com/shopify/engineering/_apis/search/codesearchresults`, + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Basic OkFCQw=='); + expect(req.body).toEqual({ + searchText: 'path:/catalog-info.yaml repo:*', + $skip: 0, + $top: 1000, + }); + return res(ctx.json(response)); + }, + ), + ); + + await expect( + codeSearch( + { host: 'dev.azure.com', token: 'ABC' }, + 'shopify', + 'engineering', + '', + '/catalog-info.yaml', + ), + ).resolves.toEqual(response.results); + }); + + it('searches in specific repo if parameter is set', async () => { + const response: CodeSearchResponse = { + count: 1, + results: [ + { + fileName: 'catalog-info.yaml', + path: '/catalog-info.yaml', + repository: { + name: 'backstage', + }, + }, + ], + }; + + server.use( + rest.post( + `https://almsearch.dev.azure.com/shopify/engineering/_apis/search/codesearchresults`, + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Basic OkFCQw=='); + expect(req.body).toEqual({ + searchText: 'path:/catalog-info.yaml repo:backstage', + $skip: 0, + $top: 1000, + }); + return res(ctx.json(response)); + }, + ), + ); + + await expect( + codeSearch( + { host: 'dev.azure.com', token: 'ABC' }, + 'shopify', + 'engineering', + 'backstage', + '/catalog-info.yaml', + ), + ).resolves.toEqual(response.results); + }); + + it('can search using onpremise api', async () => { + const response: CodeSearchResponse = { + count: 1, + results: [ + { + fileName: 'catalog-info.yaml', + path: '/catalog-info.yaml', + repository: { + name: 'backstage', + }, + }, + ], + }; + + server.use( + rest.post( + `https://azuredevops.mycompany.com/shopify/engineering/_apis/search/codesearchresults`, + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Basic OkFCQw=='); + expect(req.body).toEqual({ + searchText: 'path:/catalog-info.yaml repo:*', + $skip: 0, + $top: 1000, + }); + return res(ctx.json(response)); + }, + ), + ); + + await expect( + codeSearch( + { host: 'azuredevops.mycompany.com', token: 'ABC' }, + 'shopify', + 'engineering', + '', + '/catalog-info.yaml', + ), + ).resolves.toEqual(response.results); + }); + + it('searches multiple pages if response contains many items', async () => { + const totalCount = 2401; + const generateItems = (count: number) => { + return Array.from(Array(count).keys()).map(_ => ({ + fileName: 'catalog-info.yaml', + path: '/catalog-info.yaml', + repository: { + name: 'backstage', + }, + })); + }; + + server.use( + rest.post( + `https://almsearch.dev.azure.com/shopify/engineering/_apis/search/codesearchresults`, + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Basic OkFCQw=='); + expect(req.body).toMatchObject({ + searchText: 'path:/catalog-info.yaml repo:backstage', + $top: 1000, + }); + + const body = req.body as { $skip: number; $top: number }; + const countItemsToReturn = + body.$top + body.$skip > totalCount + ? totalCount - body.$skip + : body.$top; + + return res( + ctx.json({ + count: totalCount, + results: generateItems(countItemsToReturn), + }), + ); + }, + ), + ); + + await expect( + codeSearch( + { host: 'dev.azure.com', token: 'ABC' }, + 'shopify', + 'engineering', + 'backstage', + '/catalog-info.yaml', + ), + ).resolves.toHaveLength(totalCount); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/azure/azure.ts b/plugins/catalog-backend/src/ingestion/processors/azure/azure.ts new file mode 100644 index 0000000000..316c9b0b8c --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/azure/azure.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fetch from 'node-fetch'; +import { + AzureIntegrationConfig, + getAzureRequestOptions, +} from '@backstage/integration'; + +export interface CodeSearchResponse { + count: number; + results: CodeSearchResultItem[]; +} + +export interface CodeSearchResultItem { + fileName: string; + path: string; + repository: { + name: string; + }; +} + +const isCloud = (host: string) => host === 'dev.azure.com'; +const PAGE_SIZE = 1000; + +// codeSearch returns all files that matches the given search path. +export async function codeSearch( + azureConfig: AzureIntegrationConfig, + org: string, + project: string, + repo: string, + path: string, +): Promise { + const searchBaseUrl = isCloud(azureConfig.host) + ? 'https://almsearch.dev.azure.com' + : `https://${azureConfig.host}`; + const searchUrl = `${searchBaseUrl}/${org}/${project}/_apis/search/codesearchresults?api-version=6.0-preview.1`; + + let items: CodeSearchResultItem[] = []; + let hasMorePages = true; + + do { + const response = await fetch(searchUrl, { + ...getAzureRequestOptions(azureConfig, { + 'Content-Type': 'application/json', + }), + method: 'POST', + body: JSON.stringify({ + searchText: `path:${path} repo:${repo || '*'}`, + $skip: items.length, + $top: PAGE_SIZE, + }), + }); + + if (response.status !== 200) { + throw new Error( + `Azure DevOps search failed with response status ${response.status}`, + ); + } + + const body: CodeSearchResponse = await response.json(); + items = [...items, ...body.results]; + hasMorePages = body.count > items.length; + } while (hasMorePages); + + return items; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/azure/index.ts b/plugins/catalog-backend/src/ingestion/processors/azure/index.ts new file mode 100644 index 0000000000..d4ff56c4c0 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/azure/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './azure'; diff --git a/plugins/catalog-backend/src/ingestion/processors/bitbucket/client.ts b/plugins/catalog-backend/src/ingestion/processors/bitbucket/client.ts index b578799229..0c132bb61b 100644 --- a/plugins/catalog-backend/src/ingestion/processors/bitbucket/client.ts +++ b/plugins/catalog-backend/src/ingestion/processors/bitbucket/client.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import fetch from 'cross-fetch'; +import fetch from 'node-fetch'; import { BitbucketIntegrationConfig, diff --git a/plugins/catalog-backend/src/ingestion/processors/gitlab/client.ts b/plugins/catalog-backend/src/ingestion/processors/gitlab/client.ts index 8781e071f7..d1765521a3 100644 --- a/plugins/catalog-backend/src/ingestion/processors/gitlab/client.ts +++ b/plugins/catalog-backend/src/ingestion/processors/gitlab/client.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import fetch from 'cross-fetch'; +import fetch from 'node-fetch'; import { getGitLabRequestOptions, GitLabIntegrationConfig, diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index 4f3c502a8e..63feac51af 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -26,6 +26,7 @@ export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; export { CodeOwnersProcessor } from './CodeOwnersProcessor'; export { FileReaderProcessor } from './FileReaderProcessor'; export { GithubDiscoveryProcessor } from './GithubDiscoveryProcessor'; +export { AzureDevOpsDiscoveryProcessor } from './AzureDevOpsDiscoveryProcessor'; export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor'; export { GithubMultiOrgReaderProcessor } from './GithubMultiOrgReaderProcessor'; export { GitLabDiscoveryProcessor } from './GitLabDiscoveryProcessor'; diff --git a/plugins/catalog-backend/src/legacy/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/legacy/database/CommonDatabase.test.ts index ca2e6c2934..8b72b470a6 100644 --- a/plugins/catalog-backend/src/legacy/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/legacy/database/CommonDatabase.test.ts @@ -536,9 +536,7 @@ describe('CommonDatabase', () => { filter: { anyOf: [ { - allOf: [ - { key: 'metadata.annotations.foo', matchValueExists: true }, - ], + allOf: [{ key: 'metadata.annotations.foo' }], }, ], }, @@ -558,30 +556,6 @@ describe('CommonDatabase', () => { }, ]), ); - - const nonExistRows = await db.transaction(async tx => - db.entities(tx, { - filter: { - anyOf: [ - { - allOf: [ - { key: 'metadata.annotations.foo', matchValueExists: false }, - ], - }, - ], - }, - }), - ); - - expect(nonExistRows.entities.length).toEqual(1); - expect(nonExistRows.entities).toEqual( - expect.arrayContaining([ - { - locationId: undefined, - entity: expect.objectContaining({ kind: 'k3' }), - }, - ]), - ); }); }); diff --git a/plugins/catalog-backend/src/legacy/database/CommonDatabase.ts b/plugins/catalog-backend/src/legacy/database/CommonDatabase.ts index 6a150f7583..3ea8696437 100644 --- a/plugins/catalog-backend/src/legacy/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/legacy/database/CommonDatabase.ts @@ -224,7 +224,8 @@ export class CommonDatabase implements Database { if ( request?.filter && (request.filter.hasOwnProperty('key') || - request.filter.hasOwnProperty('allOf')) + request.filter.hasOwnProperty('allOf') || + request.filter.hasOwnProperty('not')) ) { throw new Error( 'Filters for the legacy CommonDatabase must obey the { anyOf: [{ allOf: [] }] } format.', @@ -236,13 +237,14 @@ export class CommonDatabase implements Database { for (const filter of singleFilter.allOf) { if ( filter.hasOwnProperty('anyOf') || - filter.hasOwnProperty('allOf') + filter.hasOwnProperty('allOf') || + filter.hasOwnProperty('not') ) { throw new Error( 'Nested filters are not supported in the legacy CommonDatabase', ); } - const { key, matchValueIn, matchValueExists } = filter; + const { key, values } = filter; // NOTE(freben): This used to be a set of OUTER JOIN, which may seem to // make a lot of sense. However, it had abysmal performance on sqlite // when datasets grew large, so we're using IN instead. @@ -250,24 +252,19 @@ export class CommonDatabase implements Database { .select('entity_id') .where(function keyFilter() { this.andWhere({ key: key.toLowerCase() }); - if (matchValueExists !== false && matchValueIn) { - if (matchValueIn.length === 1) { - this.andWhere({ value: matchValueIn[0].toLowerCase() }); - } else if (matchValueIn.length > 1) { + if (values) { + if (values.length === 1) { + this.andWhere({ value: values[0].toLowerCase() }); + } else if (values.length > 1) { this.andWhere( 'value', 'in', - matchValueIn.map(v => v.toLowerCase()), + values.map(v => v.toLowerCase()), ); } } }); - // Explicitly evaluate matchValueExists as a boolean since it may be undefined - this.andWhere( - 'id', - matchValueExists === false ? 'not in' : 'in', - matchQuery, - ); + this.andWhere('id', 'in', matchQuery); } }); } diff --git a/plugins/catalog-backend/src/legacy/service/CatalogBuilder.ts b/plugins/catalog-backend/src/legacy/service/CatalogBuilder.ts index b8e45c7cf3..6fb52e245e 100644 --- a/plugins/catalog-backend/src/legacy/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/legacy/service/CatalogBuilder.ts @@ -42,6 +42,7 @@ import { CodeOwnersProcessor, FileReaderProcessor, GithubDiscoveryProcessor, + AzureDevOpsDiscoveryProcessor, GithubOrgReaderProcessor, GitLabDiscoveryProcessor, LocationEntityProcessor, @@ -317,6 +318,7 @@ export class CatalogBuilder { new FileReaderProcessor(), BitbucketDiscoveryProcessor.fromConfig(config, { logger }), GithubDiscoveryProcessor.fromConfig(config, { logger }), + AzureDevOpsDiscoveryProcessor.fromConfig(config, { logger }), GithubOrgReaderProcessor.fromConfig(config, { logger }), GitLabDiscoveryProcessor.fromConfig(config, { logger }), new UrlReaderProcessor({ reader, logger }), diff --git a/plugins/catalog-backend/src/legacy/service/router.test.ts b/plugins/catalog-backend/src/legacy/service/router.test.ts index edc7d852ac..0ff5a44ed0 100644 --- a/plugins/catalog-backend/src/legacy/service/router.test.ts +++ b/plugins/catalog-backend/src/legacy/service/router.test.ts @@ -115,11 +115,11 @@ describe('createRouter readonly disabled', () => { anyOf: [ { allOf: [ - { key: 'a', matchValueIn: ['1', '2'] }, - { key: 'b', matchValueIn: ['3'] }, + { key: 'a', values: ['1', '2'] }, + { key: 'b', values: ['3'] }, ], }, - { allOf: [{ key: 'c', matchValueIn: ['4'] }] }, + { allOf: [{ key: 'c', values: ['4'] }] }, ], }, }); diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts index 6b55b8b9eb..b93ee758d7 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts @@ -61,7 +61,6 @@ describe('DefaultCatalogProcessingEngine', () => { }); const engine = new DefaultCatalogProcessingEngine( getVoidLogger(), - [], db, orchestrator, stitcher, @@ -123,7 +122,6 @@ describe('DefaultCatalogProcessingEngine', () => { }); const engine = new DefaultCatalogProcessingEngine( getVoidLogger(), - [], db, orchestrator, stitcher, @@ -201,7 +199,6 @@ describe('DefaultCatalogProcessingEngine', () => { const engine = new DefaultCatalogProcessingEngine( getVoidLogger(), - [], db, orchestrator, stitcher, @@ -273,7 +270,6 @@ describe('DefaultCatalogProcessingEngine', () => { const engine = new DefaultCatalogProcessingEngine( getVoidLogger(), - [], db, orchestrator, stitcher, diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts index 1ce32a01e1..fb42af778b 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts @@ -14,11 +14,7 @@ * limitations under the License. */ -import { - Entity, - entityEnvelopeSchemaValidator, - stringifyEntityRef, -} from '@backstage/catalog-model'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { assertError, serializeError } from '@backstage/errors'; import { Hash } from 'crypto'; import stableStringify from 'fast-json-stable-stringify'; @@ -32,68 +28,15 @@ import { } from '../processing/types'; import { Stitcher } from '../stitching/Stitcher'; import { startTaskPipeline } from './TaskPipeline'; -import { - EntityProvider, - EntityProviderConnection, - EntityProviderMutation, -} from '../providers/types'; const CACHE_TTL = 5; -class Connection implements EntityProviderConnection { - readonly validateEntityEnvelope = entityEnvelopeSchemaValidator(); - - constructor( - private readonly config: { - id: string; - processingDatabase: ProcessingDatabase; - }, - ) {} - - async applyMutation(mutation: EntityProviderMutation): Promise { - const db = this.config.processingDatabase; - - if (mutation.type === 'full') { - this.check(mutation.entities.map(e => e.entity)); - await db.transaction(async tx => { - await db.replaceUnprocessedEntities(tx, { - sourceKey: this.config.id, - type: 'full', - items: mutation.entities, - }); - }); - } else if (mutation.type === 'delta') { - this.check(mutation.added.map(e => e.entity)); - this.check(mutation.removed.map(e => e.entity)); - await db.transaction(async tx => { - await db.replaceUnprocessedEntities(tx, { - sourceKey: this.config.id, - type: 'delta', - added: mutation.added, - removed: mutation.removed, - }); - }); - } - } - - private check(entities: Entity[]) { - for (const entity of entities) { - try { - this.validateEntityEnvelope(entity); - } catch (e) { - throw new TypeError(`Malformed entity envelope, ${e}`); - } - } - } -} - export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { private readonly tracker = progressTracker(); private stopFunc?: () => void; constructor( private readonly logger: Logger, - private readonly entityProviders: EntityProvider[], private readonly processingDatabase: ProcessingDatabase, private readonly orchestrator: CatalogProcessingOrchestrator, private readonly stitcher: Stitcher, @@ -106,15 +49,6 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { throw new Error('Processing engine is already started'); } - for (const provider of this.entityProviders) { - await provider.connect( - new Connection({ - id: provider.getProviderName(), - processingDatabase: this.processingDatabase, - }), - ); - } - this.stopFunc = startTaskPipeline({ lowWatermark: 5, highWatermark: 10, diff --git a/plugins/catalog-backend/src/processing/connectEntityProviders.ts b/plugins/catalog-backend/src/processing/connectEntityProviders.ts new file mode 100644 index 0000000000..02bdc2aee3 --- /dev/null +++ b/plugins/catalog-backend/src/processing/connectEntityProviders.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, + entityEnvelopeSchemaValidator, +} from '@backstage/catalog-model'; +import { ProcessingDatabase } from '../database/types'; +import { + EntityProvider, + EntityProviderConnection, + EntityProviderMutation, +} from '../providers/types'; + +class Connection implements EntityProviderConnection { + readonly validateEntityEnvelope = entityEnvelopeSchemaValidator(); + + constructor( + private readonly config: { + id: string; + processingDatabase: ProcessingDatabase; + }, + ) {} + + async applyMutation(mutation: EntityProviderMutation): Promise { + const db = this.config.processingDatabase; + + if (mutation.type === 'full') { + this.check(mutation.entities.map(e => e.entity)); + await db.transaction(async tx => { + await db.replaceUnprocessedEntities(tx, { + sourceKey: this.config.id, + type: 'full', + items: mutation.entities, + }); + }); + } else if (mutation.type === 'delta') { + this.check(mutation.added.map(e => e.entity)); + this.check(mutation.removed.map(e => e.entity)); + await db.transaction(async tx => { + await db.replaceUnprocessedEntities(tx, { + sourceKey: this.config.id, + type: 'delta', + added: mutation.added, + removed: mutation.removed, + }); + }); + } + } + + private check(entities: Entity[]) { + for (const entity of entities) { + try { + this.validateEntityEnvelope(entity); + } catch (e) { + throw new TypeError(`Malformed entity envelope, ${e}`); + } + } + } +} + +export async function connectEntityProviders( + db: ProcessingDatabase, + providers: EntityProvider[], +) { + await Promise.all( + providers.map(async provider => { + const connection = new Connection({ + id: provider.getProviderName(), + processingDatabase: db, + }); + return provider.connect(connection); + }), + ); +} diff --git a/plugins/catalog-backend/src/run.ts b/plugins/catalog-backend/src/run.ts index 54d2716290..0a3ed2b7f0 100644 --- a/plugins/catalog-backend/src/run.ts +++ b/plugins/catalog-backend/src/run.ts @@ -18,7 +18,7 @@ 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 port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts index 20e141b921..1360ca2647 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { + PluginEndpointDiscovery, + TokenManager, +} from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { DefaultCatalogCollator } from './DefaultCatalogCollator'; import { setupServer } from 'msw/node'; @@ -55,19 +58,27 @@ const expectedEntities: Entity[] = [ describe('DefaultCatalogCollator', () => { let mockDiscoveryApi: jest.Mocked; + let mockTokenManager: jest.Mocked; let collator: DefaultCatalogCollator; beforeAll(() => { mockDiscoveryApi = { - getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000'), + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007'), getExternalBaseUrl: jest.fn(), }; - collator = new DefaultCatalogCollator({ discovery: mockDiscoveryApi }); + mockTokenManager = { + getToken: jest.fn().mockResolvedValue({ token: '' }), + authenticate: jest.fn(), + }; + collator = new DefaultCatalogCollator({ + discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, + }); server.listen(); }); beforeEach(() => { server.use( - rest.get('http://localhost:7000/entities', (req, res, ctx) => { + rest.get('http://localhost:7007/entities', (req, res, ctx) => { if (req.url.searchParams.has('filter')) { const filter = req.url.searchParams.get('filter'); if (filter === 'kind=Foo,kind=Bar') { @@ -118,6 +129,7 @@ describe('DefaultCatalogCollator', () => { // Provide an alternate location template. collator = new DefaultCatalogCollator({ discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, locationTemplate: '/software/:name', }); @@ -131,6 +143,7 @@ describe('DefaultCatalogCollator', () => { // Provide an alternate location template. collator = DefaultCatalogCollator.fromConfig(new ConfigReader({}), { discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, filter: { kind: ['Foo', 'Bar'], }, diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts index a28a7645c4..4c6342c6c6 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts @@ -14,8 +14,11 @@ * limitations under the License. */ -import { PluginEndpointDiscovery } from '@backstage/backend-common'; -import { Entity } from '@backstage/catalog-model'; +import { + PluginEndpointDiscovery, + TokenManager, +} from '@backstage/backend-common'; +import { Entity, UserEntity } from '@backstage/catalog-model'; import { IndexableDocument, DocumentCollator } from '@backstage/search-common'; import { Config } from '@backstage/config'; import { @@ -38,11 +41,13 @@ export class DefaultCatalogCollator implements DocumentCollator { protected filter?: CatalogEntitiesRequest['filter']; protected readonly catalogClient: CatalogApi; public readonly type: string = 'software-catalog'; + protected tokenManager: TokenManager; static fromConfig( _config: Config, options: { discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; filter?: CatalogEntitiesRequest['filter']; }, ) { @@ -56,8 +61,10 @@ export class DefaultCatalogCollator implements DocumentCollator { locationTemplate, filter, catalogClient, + tokenManager, }: { discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; locationTemplate?: string; filter?: CatalogEntitiesRequest['filter']; catalogClient?: CatalogApi; @@ -68,6 +75,7 @@ export class DefaultCatalogCollator implements DocumentCollator { this.filter = filter; this.catalogClient = catalogClient || new CatalogClient({ discoveryApi: discovery }); + this.tokenManager = tokenManager; } protected applyArgsToFormat( @@ -81,10 +89,32 @@ export class DefaultCatalogCollator implements DocumentCollator { return formatted.toLowerCase(); } + private isUserEntity(entity: Entity): entity is UserEntity { + return entity.kind.toLocaleUpperCase('en-US') === 'USER'; + } + + private getDocumentText(entity: Entity): string { + let documentText = entity.metadata.description || ''; + if (this.isUserEntity(entity)) { + if (entity.spec?.profile?.displayName && documentText) { + // combine displayName and description + const displayName = entity.spec?.profile?.displayName; + documentText = displayName.concat(' : ', documentText); + } else { + documentText = entity.spec?.profile?.displayName || documentText; + } + } + return documentText; + } + async execute() { - const response = await this.catalogClient.getEntities({ - filter: this.filter, - }); + const { token } = await this.tokenManager.getToken(); + const response = await this.catalogClient.getEntities( + { + filter: this.filter, + }, + { token }, + ); return response.items.map((entity: Entity): CatalogEntityDocument => { return { title: entity.metadata.title ?? entity.metadata.name, @@ -93,7 +123,7 @@ export class DefaultCatalogCollator implements DocumentCollator { kind: entity.kind, name: entity.metadata.name, }), - text: entity.metadata.description || '', + text: this.getDocumentText(entity), componentType: entity.spec?.type?.toString() || 'other', namespace: entity.metadata.namespace || 'default', kind: entity.kind, diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts index a089989d20..562bae4867 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts @@ -148,6 +148,57 @@ describe('DefaultLocationServiceTest', () => { expect(result.exists).toBe(true); }); + it('should fail when there are duplicate entities using dry run', async () => { + store.listLocations.mockResolvedValueOnce([]); + orchestrator.process.mockResolvedValueOnce({ + ok: true, + state: {}, + completedEntity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + name: 'foo', + }, + }, + deferredEntities: [ + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + name: 'foo', + }, + }, + locationKey: 'file:///tmp/mock.yaml', + }, + ], + relations: [], + errors: [], + }); + + orchestrator.process.mockResolvedValueOnce({ + ok: true, + state: {}, + completedEntity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + name: 'foo', + }, + }, + deferredEntities: [], + relations: [], + errors: [], + }); + + await expect( + locationService.createLocation( + { type: 'url', target: 'https://backstage.io/catalog-info.yaml' }, + true, + ), + ).rejects.toThrowError('Duplicate nested entity: location:default/foo'); + }); + it('should return exists false when the location does not exist beforehand', async () => { orchestrator.process.mockResolvedValueOnce({ ok: true, diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.ts b/plugins/catalog-backend/src/service/DefaultLocationService.ts index 2166341935..8dd2274842 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.ts @@ -19,6 +19,7 @@ import { LocationSpec, LOCATION_ANNOTATION, ORIGIN_LOCATION_ANNOTATION, + stringifyEntityRef, } from '@backstage/catalog-model'; import { CatalogProcessingOrchestrator, @@ -54,6 +55,43 @@ export class DefaultLocationService implements LocationService { return this.store.deleteLocation(id); } + private async processEntities( + unprocessedEntities: DeferredEntity[], + ): Promise { + const entities: Entity[] = []; + while (unprocessedEntities.length) { + const currentEntity = unprocessedEntities.pop(); + if (!currentEntity) { + continue; + } + const processed = await this.orchestrator.process({ + entity: currentEntity.entity, + state: {}, // we process without the existing cache + }); + + if (processed.ok) { + if ( + entities.some( + e => + stringifyEntityRef(e) === + stringifyEntityRef(processed.completedEntity), + ) + ) { + throw new Error( + `Duplicate nested entity: ${stringifyEntityRef( + processed.completedEntity, + )}`, + ); + } + unprocessedEntities.push(...processed.deferredEntities); + entities.push(processed.completedEntity); + } else { + throw Error(processed.errors.map(String).join(', ')); + } + } + return entities; + } + private async dryRunCreateLocation( spec: LocationSpec, ): Promise<{ location: Location; entities: Entity[]; exists?: boolean }> { @@ -86,24 +124,7 @@ export class DefaultLocationService implements LocationService { const unprocessedEntities: DeferredEntity[] = [ { entity, locationKey: `${spec.type}:${spec.target}` }, ]; - const entities: Entity[] = []; - while (unprocessedEntities.length) { - const currentEntity = unprocessedEntities.pop(); - if (!currentEntity) { - continue; - } - const processed = await this.orchestrator.process({ - entity: currentEntity.entity, - state: {}, // we process without the existing cache - }); - - if (processed.ok) { - unprocessedEntities.push(...processed.deferredEntities); - entities.push(processed.completedEntity); - } else { - throw Error(processed.errors.map(String).join(', ')); - } - } + const entities: Entity[] = await this.processEntities(unprocessedEntities); return { exists: await existsPromise, diff --git a/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts b/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts index 0ea475a528..1876e59f95 100644 --- a/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts @@ -103,7 +103,6 @@ describe('Refresh integration', () => { const engine = new DefaultCatalogProcessingEngine( defaultLogger, - [], db, { async process(request: EntityProcessingRequest) { diff --git a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts index bbda6f070c..c9ebc8f723 100644 --- a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts @@ -44,6 +44,7 @@ import { CatalogProcessorParser, CodeOwnersProcessor, FileReaderProcessor, + AzureDevOpsDiscoveryProcessor, GithubDiscoveryProcessor, GithubOrgReaderProcessor, GitLabDiscoveryProcessor, @@ -80,6 +81,7 @@ import { DefaultCatalogRulesEnforcer } from '../ingestion/CatalogRules'; import { Config } from '@backstage/config'; import { Logger } from 'winston'; import { LocationService } from './types'; +import { connectEntityProviders } from '../processing/connectEntityProviders'; export type CatalogEnvironment = { logger: Logger; @@ -291,6 +293,7 @@ export class NextCatalogBuilder { return [ new FileReaderProcessor(), BitbucketDiscoveryProcessor.fromConfig(config, { logger }), + AzureDevOpsDiscoveryProcessor.fromConfig(config, { logger }), GithubDiscoveryProcessor.fromConfig(config, { logger }), GithubOrgReaderProcessor.fromConfig(config, { logger }), GitLabDiscoveryProcessor.fromConfig(config, { logger }), @@ -364,7 +367,6 @@ export class NextCatalogBuilder { const processingEngine = new DefaultCatalogProcessingEngine( logger, - entityProviders, processingDatabase, orchestrator, stitcher, @@ -390,6 +392,8 @@ export class NextCatalogBuilder { config, }); + await connectEntityProviders(processingDatabase, entityProviders); + return { entitiesCatalog, locationsCatalog, diff --git a/plugins/catalog-backend/src/service/NextEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/NextEntitiesCatalog.test.ts index 32d5fa843e..d0f284c11c 100644 --- a/plugins/catalog-backend/src/service/NextEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/NextEntitiesCatalog.test.ts @@ -282,7 +282,6 @@ describe('NextEntitiesCatalog', () => { const testFilter = { key: 'spec.test', - matchValueExists: true, }; const request = { filter: testFilter }; const { entities } = await catalog.entities(request); @@ -293,7 +292,42 @@ describe('NextEntitiesCatalog', () => { ); it.each(databases.eachSupportedId())( - 'should return correct entity for nested filter', + 'should return correct entity for negation filter', + async databaseId => { + const { knex } = await createDatabase(databaseId); + const entity1: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'one' }, + spec: {}, + }; + const entity2: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'two' }, + spec: { + test: 'test value', + }, + }; + await addEntityToSearch(knex, entity1); + await addEntityToSearch(knex, entity2); + const catalog = new NextEntitiesCatalog(knex); + + const testFilter = { + not: { + key: 'spec.test', + }, + }; + const request = { filter: testFilter }; + const { entities } = await catalog.entities(request); + + expect(entities.length).toBe(1); + expect(entities[0]).toEqual(entity1); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return correct entities for nested filter', async databaseId => { const { knex } = await createDatabase(databaseId); const entity1: Entity = { @@ -328,24 +362,27 @@ describe('NextEntitiesCatalog', () => { const testFilter1 = { key: 'metadata.org', - matchValueExists: true, - matchValueIn: ['b'], + values: ['b'], }; const testFilter2 = { key: 'metadata.desc', - matchValueExists: true, }; const testFilter3 = { key: 'metadata.color', - matchValueExists: true, - matchValueIn: ['blue'], + values: ['blue'], + }; + const testFilter4 = { + not: { + key: 'metadata.color', + values: ['red'], + }, }; const request = { filter: { allOf: [ testFilter1, { - anyOf: [testFilter2, testFilter3], + anyOf: [testFilter2, testFilter3, testFilter4], }, ], }, @@ -357,5 +394,78 @@ describe('NextEntitiesCatalog', () => { expect(entities).toContainEqual(entity4); }, ); + + it.each(databases.eachSupportedId())( + 'should return correct entities for complex negation filter', + async databaseId => { + const { knex } = await createDatabase(databaseId); + const entity1: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'one', org: 'a', desc: 'description' }, + spec: {}, + }; + const entity2: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'two', org: 'b', desc: 'description' }, + spec: {}, + }; + await addEntityToSearch(knex, entity1); + await addEntityToSearch(knex, entity2); + const catalog = new NextEntitiesCatalog(knex); + + const testFilter1 = { + key: 'metadata.org', + values: ['b'], + }; + const testFilter2 = { + key: 'metadata.desc', + }; + const request = { + filter: { + not: { + allOf: [testFilter1, testFilter2], + }, + }, + }; + const { entities } = await catalog.entities(request); + + expect(entities.length).toBe(1); + expect(entities).toContainEqual(entity1); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return no matches for an empty values array', + // NOTE: An empty values array is not a sensible input in a realistic scenario. + async databaseId => { + const { knex } = await createDatabase(databaseId); + const entity1: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'one' }, + spec: {}, + }; + const entity2: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'two' }, + spec: {}, + }; + await addEntityToSearch(knex, entity1); + await addEntityToSearch(knex, entity2); + const catalog = new NextEntitiesCatalog(knex); + + const testFilter = { + key: 'kind', + values: [], + }; + const request = { filter: testFilter }; + const { entities } = await catalog.entities(request); + + expect(entities.length).toBe(0); + }, + ); }); }); diff --git a/plugins/catalog-backend/src/service/NextEntitiesCatalog.ts b/plugins/catalog-backend/src/service/NextEntitiesCatalog.ts index 1c615f862a..f9b3abf190 100644 --- a/plugins/catalog-backend/src/service/NextEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/NextEntitiesCatalog.ts @@ -78,33 +78,29 @@ function stringifyPagination(input: { limit: number; offset: number }) { function addCondition( queryBuilder: Knex.QueryBuilder, db: Knex, - { key, matchValueIn, matchValueExists }: EntitiesSearchFilter, + filter: EntitiesSearchFilter, + negate: boolean = false, ) { // NOTE(freben): This used to be a set of OUTER JOIN, which may seem to // make a lot of sense. However, it had abysmal performance on sqlite // when datasets grew large, so we're using IN instead. const matchQuery = db('search') .select('entity_id') - .where(function keyFilter() { - this.andWhere({ key: key.toLowerCase() }); - if (matchValueExists !== false && matchValueIn) { - if (matchValueIn.length === 1) { - this.andWhere({ value: matchValueIn[0].toLowerCase() }); - } else if (matchValueIn.length > 1) { + .where({ key: filter.key.toLowerCase() }) + .andWhere(function keyFilter() { + if (filter.values) { + if (filter.values.length === 1) { + this.where({ value: filter.values[0].toLowerCase() }); + } else { this.andWhere( 'value', 'in', - matchValueIn.map(v => v.toLowerCase()), + filter.values.map(v => v.toLowerCase()), ); } } }); - // Explicitly evaluate matchValueExists as a boolean since it may be undefined - queryBuilder.andWhere( - 'entity_id', - matchValueExists === false ? 'not in' : 'in', - matchQuery, - ); + queryBuilder.andWhere('entity_id', negate ? 'not in' : 'in', matchQuery); } function isEntitiesSearchFilter( @@ -113,46 +109,45 @@ function isEntitiesSearchFilter( return filter.hasOwnProperty('key'); } -function isAndEntityFilter( - filter: { allOf: EntityFilter[] } | EntityFilter, -): filter is { allOf: EntityFilter[] } { - return filter.hasOwnProperty('allOf'); -} - function isOrEntityFilter( filter: { anyOf: EntityFilter[] } | EntityFilter, ): filter is { anyOf: EntityFilter[] } { return filter.hasOwnProperty('anyOf'); } +function isNegationEntityFilter( + filter: { not: EntityFilter } | EntityFilter, +): filter is { not: EntityFilter } { + return filter.hasOwnProperty('not'); +} + function parseFilter( filter: EntityFilter, query: Knex.QueryBuilder, db: Knex, + negate: boolean = false, ): Knex.QueryBuilder { if (isEntitiesSearchFilter(filter)) { return query.andWhere(function filterFunction() { - addCondition(this, db, filter); + addCondition(this, db, filter, negate); }); } - if (isOrEntityFilter(filter)) { - return query.andWhere(function filterFunction() { + if (isNegationEntityFilter(filter)) { + return parseFilter(filter.not, query, db, !negate); + } + + return query[negate ? 'andWhereNot' : 'andWhere'](function filterFunction() { + if (isOrEntityFilter(filter)) { for (const subFilter of filter.anyOf ?? []) { this.orWhere(subQuery => parseFilter(subFilter, subQuery, db)); } - }); - } - - if (isAndEntityFilter(filter)) { - return query.andWhere(function filterFunction() { + } else { for (const subFilter of filter.allOf ?? []) { this.andWhere(subQuery => parseFilter(subFilter, subQuery, db)); } - }); - } - - return query; + } + }); } export class NextEntitiesCatalog implements EntitiesCatalog { diff --git a/plugins/catalog-backend/src/service/NextRouter.test.ts b/plugins/catalog-backend/src/service/NextRouter.test.ts index 57911c74fb..6bc4481425 100644 --- a/plugins/catalog-backend/src/service/NextRouter.test.ts +++ b/plugins/catalog-backend/src/service/NextRouter.test.ts @@ -104,11 +104,11 @@ describe('createNextRouter readonly disabled', () => { anyOf: [ { allOf: [ - { key: 'a', matchValueIn: ['1', '2'] }, - { key: 'b', matchValueIn: ['3'] }, + { key: 'a', values: ['1', '2'] }, + { key: 'b', values: ['3'] }, ], }, - { allOf: [{ key: 'c', matchValueIn: ['4'] }] }, + { allOf: [{ key: 'c', values: ['4'] }] }, ], }, }); diff --git a/plugins/catalog-backend/src/service/request/basicEntityFilter.ts b/plugins/catalog-backend/src/service/request/basicEntityFilter.ts index 1cf9ca95f0..36448e0304 100644 --- a/plugins/catalog-backend/src/service/request/basicEntityFilter.ts +++ b/plugins/catalog-backend/src/service/request/basicEntityFilter.ts @@ -30,9 +30,9 @@ export function basicEntityFilter( const f = key in filtersByKey ? filtersByKey[key] - : (filtersByKey[key] = { key, matchValueIn: [] }); + : (filtersByKey[key] = { key, values: [] }); - f.matchValueIn!.push(...values); + f.values!.push(...values); } return { anyOf: [{ allOf: Object.values(filtersByKey) }] }; diff --git a/plugins/catalog-backend/src/service/request/parseEntityFilterParams.test.ts b/plugins/catalog-backend/src/service/request/parseEntityFilterParams.test.ts index fc22a638de..f9fc596cb9 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityFilterParams.test.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityFilterParams.test.ts @@ -28,7 +28,7 @@ describe('parseEntityFilterParams', () => { it('supports single-string format', () => { const result = parseEntityFilterParams({ filter: 'a=1' })!; expect(result).toEqual({ - anyOf: [{ allOf: [{ key: 'a', matchValueIn: ['1'] }] }], + anyOf: [{ allOf: [{ key: 'a', values: ['1'] }] }], }); }); @@ -38,8 +38,8 @@ describe('parseEntityFilterParams', () => { }); expect(result).toEqual({ anyOf: [ - { allOf: [{ key: 'a', matchValueIn: ['1'] }] }, - { allOf: [{ key: 'b', matchValueIn: ['2'] }] }, + { allOf: [{ key: 'a', values: ['1'] }] }, + { allOf: [{ key: 'b', values: ['2'] }] }, ], }); }); @@ -50,11 +50,11 @@ describe('parseEntityFilterParams', () => { }); expect(result).toEqual({ anyOf: [ - { allOf: [{ key: 'a', matchValueIn: ['1'] }] }, + { allOf: [{ key: 'a', values: ['1'] }] }, { allOf: [ - { key: 'b', matchValueIn: ['2', '3'] }, - { key: 'c', matchValueIn: ['4'] }, + { key: 'b', values: ['2', '3'] }, + { key: 'c', values: ['4'] }, ], }, ], @@ -70,17 +70,17 @@ describe('parseEntityFilterString', () => { it('works for the happy path', () => { expect(parseEntityFilterString('')).toBeUndefined(); expect(parseEntityFilterString('a=1,b=2,a=3,c,d=')).toEqual([ - { key: 'a', matchValueIn: ['1', '3'] }, - { key: 'b', matchValueIn: ['2'] }, - { key: 'c', matchValueExists: true }, - { key: 'd', matchValueIn: [''] }, + { key: 'a', values: ['1', '3'] }, + { key: 'b', values: ['2'] }, + { key: 'c' }, + { key: 'd', values: [''] }, ]); }); it('trims values', () => { expect(parseEntityFilterString(' a = 1 , b = 2 , a = 3 ')).toEqual([ - { key: 'a', matchValueIn: ['1', '3'] }, - { key: 'b', matchValueIn: ['2'] }, + { key: 'a', values: ['1', '3'] }, + { key: 'b', values: ['2'] }, ]); }); diff --git a/plugins/catalog-backend/src/service/request/parseEntityFilterParams.ts b/plugins/catalog-backend/src/service/request/parseEntityFilterParams.ts index 452958b7ae..1ea5d44b2c 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityFilterParams.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityFilterParams.ts @@ -75,11 +75,9 @@ export function parseEntityFilterString( const f = key in filtersByKey ? filtersByKey[key] : (filtersByKey[key] = { key }); - if (value === undefined) { - f.matchValueExists = true; - } else { - f.matchValueIn = f.matchValueIn || []; - f.matchValueIn.push(value); + if (value !== undefined) { + f.values = f.values || []; + f.values.push(value); } } diff --git a/plugins/catalog-backend/src/stitching/Stitcher.ts b/plugins/catalog-backend/src/stitching/Stitcher.ts index 507f3aa53e..b4a693a4fa 100644 --- a/plugins/catalog-backend/src/stitching/Stitcher.ts +++ b/plugins/catalog-backend/src/stitching/Stitcher.ts @@ -209,6 +209,11 @@ export class Stitcher { entity.metadata.etag = hash; } + // This may throw if the entity is invalid, so we call it before + // the final_entites write, even though we may end up not needing + // to write the search index. + const searchEntries = buildEntitySearch(entityId, entity); + const rowsChanged = await this.database( 'final_entities', ) @@ -235,7 +240,6 @@ export class Stitcher { // B writes the entity -> // B writes search -> // A writes search - const searchEntries = buildEntitySearch(entityId, entity); await this.database('search') .where({ entity_id: entityId }) .delete(); diff --git a/plugins/catalog-backend/src/stitching/buildEntitySearch.test.ts b/plugins/catalog-backend/src/stitching/buildEntitySearch.test.ts index 9043dab9ba..58551a461f 100644 --- a/plugins/catalog-backend/src/stitching/buildEntitySearch.test.ts +++ b/plugins/catalog-backend/src/stitching/buildEntitySearch.test.ts @@ -163,5 +163,51 @@ describe('buildEntitySearch', () => { { entity_id: 'eid', key: 'relations.t2', value: 'k:ns/b' }, ]); }); + + it('rejects duplicate keys', () => { + expect(() => + buildEntitySearch('eid', { + relations: [], + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'n', + Namespace: 'ns', + }, + }), + ).toThrow( + `Entity has duplicate keys that vary only in casing, 'metadata.namespace'`, + ); + + expect(() => + buildEntitySearch('eid', { + relations: [ + { type: 'dup', target: { kind: 'k', namespace: 'ns', name: 'a' } }, + { type: 'DUP', target: { kind: 'k', namespace: 'ns', name: 'b' } }, + ], + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + }), + ).toThrow( + `Entity has duplicate keys that vary only in casing, 'relations.dup'`, + ); + + expect(() => + buildEntitySearch('eid', { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + spec: { + owner: 'o', + OWNER: 'o', + lifecycle: 'production', + lifeCycle: 'production', + }, + }), + ).toThrow( + `Entity has duplicate keys that vary only in casing, 'spec.owner', 'spec.lifecycle'`, + ); + }); }); }); diff --git a/plugins/catalog-backend/src/stitching/buildEntitySearch.ts b/plugins/catalog-backend/src/stitching/buildEntitySearch.ts index ed84dfa018..b6c3eead2a 100644 --- a/plugins/catalog-backend/src/stitching/buildEntitySearch.ts +++ b/plugins/catalog-backend/src/stitching/buildEntitySearch.ts @@ -19,6 +19,7 @@ import { ENTITY_DEFAULT_NAMESPACE, stringifyEntityRef, } from '@backstage/catalog-model'; +import { InputError } from '@backstage/errors'; import { DbSearchRow } from '../database/tables'; // These are excluded in the generic loop, either because they do not make sense @@ -184,5 +185,23 @@ export function buildEntitySearch( }); } + // This validates that there are no keys that vary only in casing, such + // as `spec.foo` and `spec.Foo`. + const keys = new Set(raw.map(r => r.key)); + const lowerKeys = new Set(raw.map(r => r.key.toLocaleLowerCase('en-US'))); + if (keys.size !== lowerKeys.size) { + const difference = []; + for (const key of keys) { + const lower = key.toLocaleLowerCase('en-US'); + if (!lowerKeys.delete(lower)) { + difference.push(lower); + } + } + const badKeys = `'${difference.join("', '")}'`; + throw new InputError( + `Entity has duplicate keys that vary only in casing, ${badKeys}`, + ); + } + return mapToRows(raw, entityId); } diff --git a/plugins/catalog-graph/CHANGELOG.md b/plugins/catalog-graph/CHANGELOG.md index a156be8fe0..c107a4e758 100644 --- a/plugins/catalog-graph/CHANGELOG.md +++ b/plugins/catalog-graph/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-catalog-graph +## 0.2.2 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.1 ### Patch Changes diff --git a/plugins/catalog-graph/package.json b/plugins/catalog-graph/package.json index e58d66b1f2..2a44593a4a 100644 --- a/plugins/catalog-graph/package.json +++ b/plugins/catalog-graph/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-graph", - "version": "0.2.1", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,12 +21,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -41,10 +41,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx index 9fe482a30b..3c167946d4 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -14,14 +14,19 @@ * limitations under the License. */ import { Entity } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { analyticsApiRef } from '@backstage/core-plugin-api'; import { CatalogApi, catalogApiRef, EntityProvider, } from '@backstage/plugin-catalog-react'; -import { MockAnalyticsApi, renderInTestApp } from '@backstage/test-utils'; +import { + MockAnalyticsApi, + renderInTestApp, + TestApiProvider, + TestApiRegistry, +} from '@backstage/test-utils'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { catalogEntityRouteRef, catalogGraphRouteRef } from '../../routes'; @@ -31,7 +36,7 @@ describe('', () => { let entity: Entity; let wrapper: JSX.Element; let catalog: jest.Mocked; - let apis: ApiRegistry; + let apis: TestApiRegistry; beforeAll(() => { Object.defineProperty(window.SVGElement.prototype, 'getBBox', { @@ -61,7 +66,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), }; - apis = ApiRegistry.with(catalogApiRef, catalog); + apis = TestApiRegistry.from([catalogApiRef, catalog]); wrapper = ( @@ -123,9 +128,9 @@ describe('', () => { test('captures analytics event on click', async () => { const analyticsSpy = new MockAnalyticsApi(); const { findByText } = await renderInTestApp( - + {wrapper} - , + , { mountedRoutes: { '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx index b992d0c976..7bd1045fff 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -14,10 +14,13 @@ * limitations under the License. */ import { RELATION_HAS_PART, RELATION_PART_OF } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { analyticsApiRef } from '@backstage/core-plugin-api'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; -import { MockAnalyticsApi, renderInTestApp } from '@backstage/test-utils'; +import { + MockAnalyticsApi, + renderInTestApp, + TestApiProvider, +} from '@backstage/test-utils'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { catalogEntityRouteRef } from '../../routes'; @@ -90,10 +93,9 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), }; - const apis = ApiRegistry.with(catalogApiRef, catalog); wrapper = ( - + ', () => { selectedKinds: ['b'], }} /> - + ); }); @@ -172,9 +174,9 @@ describe('', () => { test('should capture analytics event when selecting other entity', async () => { const analyticsSpy = new MockAnalyticsApi(); const { getByText, findAllByTestId } = await renderInTestApp( - + {wrapper} - , + , { mountedRoutes: { '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, @@ -195,9 +197,9 @@ describe('', () => { test('should capture analytics event when navigating to entity', async () => { const analyticsSpy = new MockAnalyticsApi(); const { getByText, findAllByTestId } = await renderInTestApp( - + {wrapper} - , + , { mountedRoutes: { '/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef, diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx index 2d6c5cc93d..be0c4dc5e5 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx @@ -21,9 +21,8 @@ import { RELATION_PART_OF, stringifyEntityRef, } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import userEvent from '@testing-library/user-event'; import React, { FunctionComponent } from 'react'; import { EntityRelationsGraph } from './EntityRelationsGraph'; @@ -158,10 +157,11 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), }; - const apis = ApiRegistry.with(catalogApiRef, catalog); Wrapper = ({ children }) => ( - {children} + + {children} + ); }); diff --git a/plugins/catalog-graph/src/routes.ts b/plugins/catalog-graph/src/routes.ts index 9f8372c7a5..3bca6ce411 100644 --- a/plugins/catalog-graph/src/routes.ts +++ b/plugins/catalog-graph/src/routes.ts @@ -24,8 +24,7 @@ import { * @public */ export const catalogGraphRouteRef = createRouteRef({ - path: '/catalog-graph', - title: 'Catalog Graph', + id: 'catalog-graph', }); /** diff --git a/plugins/catalog-graphql/CHANGELOG.md b/plugins/catalog-graphql/CHANGELOG.md index e123f3b17f..fe6cc4cc00 100644 --- a/plugins/catalog-graphql/CHANGELOG.md +++ b/plugins/catalog-graphql/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-catalog-graphql +## 0.2.14 + +### Patch Changes + +- 6b500622d5: Move to using node-fetch internally instead of cross-fetch + ## 0.2.13 ### Patch Changes diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 5df4f9ba5c..9ed6918110 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-graphql", "description": "An experimental Backstage catalog GraphQL module", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,20 +31,20 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", "@backstage/types": "^0.1.1", "@graphql-modules/core": "^0.7.17", "apollo-server": "^2.16.1", - "cross-fetch": "^3.0.6", "graphql": "^15.3.0", "graphql-tag": "^2.11.0", "graphql-type-json": "^0.3.2", + "node-fetch": "^2.6.1", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/test-utils": "^0.1.20", + "@backstage/cli": "^0.10.0", + "@backstage/test-utils": "^0.1.22", "@graphql-codegen/cli": "^1.21.3", "@graphql-codegen/typescript": "^1.17.7", "@graphql-codegen/typescript-resolvers": "^1.17.7", diff --git a/plugins/catalog-graphql/src/service/client.ts b/plugins/catalog-graphql/src/service/client.ts index 4328a5bd5a..8d517fcbd6 100644 --- a/plugins/catalog-graphql/src/service/client.ts +++ b/plugins/catalog-graphql/src/service/client.ts @@ -15,7 +15,7 @@ */ import { Entity, EntityMeta } from '@backstage/catalog-model'; -import fetch from 'cross-fetch'; +import fetch from 'node-fetch'; import { JsonObject } from '@backstage/types'; export interface ReaderEntityMeta extends EntityMeta { @@ -26,11 +26,14 @@ export interface ReaderEntityMeta extends EntityMeta { annotations: Record; labels: Record; } + export interface ReaderEntity extends Entity { metadata: JsonObject & ReaderEntityMeta; } + export class CatalogClient { constructor(private baseUrl: string) {} + async list(): Promise { const res = await fetch(`${this.baseUrl}/catalog/entities`); if (!res.ok) { @@ -39,7 +42,7 @@ export class CatalogClient { throw new Error(await res.text()); } - const entities: ReaderEntity[] = await res.json(); + const entities = (await res.json()) as ReaderEntity[]; return entities; } } diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index d06f38698b..6397718fd7 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-catalog-import +## 0.7.4 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/integration-react@0.1.14 + ## 0.7.3 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 242231bf81..b370c9fa33 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-import", "description": "A Backstage plugin the helps you import entities into your catalog", - "version": "0.7.3", + "version": "0.7.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,14 +31,14 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", "@backstage/errors": "^0.1.3", - "@backstage/integration": "^0.6.8", - "@backstage/integration-react": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", + "@backstage/integration": "^0.6.10", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-catalog-react": "^0.6.4", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -55,10 +55,10 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx index 69ae38207d..784cbfbe9e 100644 --- a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx @@ -15,14 +15,10 @@ */ import { CatalogClient } from '@backstage/catalog-client'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { configApiRef } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import React from 'react'; import { catalogImportApiRef, CatalogImportClient } from '../../api'; import { DefaultImportPage } from './DefaultImportPage'; @@ -43,15 +39,13 @@ describe('', () => { }, }; - let apis: ApiRegistry; + let apis: TestApiRegistry; beforeEach(() => { - apis = ApiRegistry.with( - configApiRef, - new ConfigReader({ integrations: {} }), - ) - .with(catalogApiRef, new CatalogClient({ discoveryApi: {} as any })) - .with( + apis = TestApiRegistry.from( + [configApiRef, new ConfigReader({ integrations: {} })], + [catalogApiRef, new CatalogClient({ discoveryApi: {} as any })], + [ catalogImportApiRef, new CatalogImportClient({ discoveryApi: {} as any, @@ -63,7 +57,8 @@ describe('', () => { catalogApi: {} as any, configApi: {} as any, }), - ); + ], + ); }); it('renders without exploding', async () => { diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx index 4e0c3694cd..9a27532c57 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx @@ -14,19 +14,19 @@ * limitations under the License. */ -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { configApiRef } from '@backstage/core-plugin-api'; -import { renderInTestApp } from '@backstage/test-utils'; +import { + renderInTestApp, + TestApiProvider, + TestApiRegistry, +} from '@backstage/test-utils'; import React from 'react'; import { CatalogImportApi, catalogImportApiRef } from '../../api'; import { ImportInfoCard } from './ImportInfoCard'; describe('', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; let catalogImportApi: jest.Mocked; beforeEach(() => { @@ -35,26 +35,29 @@ describe('', () => { submitPullRequest: jest.fn(), }; - apis = ApiRegistry.with( - configApiRef, - new ConfigReader({ - integrations: { - github: [{ token: 'my-token' }], - }, - }), - ).with(catalogImportApiRef, catalogImportApi); + apis = TestApiRegistry.from( + [ + configApiRef, + new ConfigReader({ + integrations: { + github: [{ token: 'my-token' }], + }, + }), + ], + [catalogImportApiRef, catalogImportApi], + ); }); it('renders without exploding', async () => { - apis = ApiRegistry.with( - configApiRef, - new ConfigReader({ integrations: {} }), - ).with(catalogImportApiRef, catalogImportApi); - const { getByText } = await renderInTestApp( - + - , + , ); expect(getByText('Register an existing component')).toBeInTheDocument(); diff --git a/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx index 92a6077792..1778af73dc 100644 --- a/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx +++ b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx @@ -15,14 +15,10 @@ */ import { CatalogClient } from '@backstage/catalog-client'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { configApiRef } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import React from 'react'; import { useOutlet } from 'react-router'; import { catalogImportApiRef, CatalogImportClient } from '../../api'; @@ -49,15 +45,13 @@ describe('', () => { }, }; - let apis: ApiRegistry; + let apis: TestApiRegistry; beforeEach(() => { - apis = ApiRegistry.with( - configApiRef, - new ConfigReader({ integrations: {} }), - ) - .with(catalogApiRef, new CatalogClient({ discoveryApi: {} as any })) - .with( + apis = TestApiRegistry.from( + [configApiRef, new ConfigReader({ integrations: {} })], + [catalogApiRef, new CatalogClient({ discoveryApi: {} as any })], + [ catalogImportApiRef, new CatalogImportClient({ discoveryApi: {} as any, @@ -67,7 +61,8 @@ describe('', () => { catalogApi: {} as any, configApi: new ConfigReader({}), }), - ); + ], + ); }); afterEach(() => jest.resetAllMocks()); diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx index 4a72a653ea..8d28fb8604 100644 --- a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; import { act, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -34,14 +34,14 @@ describe('', () => { }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} - + ); const location = { diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx index 0a55d6337b..3b6f353155 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { TestApiProvider } from '@backstage/test-utils'; import { TextField } from '@material-ui/core'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -54,13 +54,15 @@ describe('', () => { }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} - + ); const onPrepareFn = jest.fn(); diff --git a/plugins/catalog-import/src/plugin.ts b/plugins/catalog-import/src/plugin.ts index d7493edcca..52c1929dce 100644 --- a/plugins/catalog-import/src/plugin.ts +++ b/plugins/catalog-import/src/plugin.ts @@ -31,8 +31,7 @@ import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { catalogImportApiRef, CatalogImportClient } from './api'; export const rootRouteRef = createRouteRef({ - path: '', - title: 'catalog-import', + id: 'catalog-import', }); export const catalogImportPlugin = createPlugin({ diff --git a/plugins/catalog-react/CHANGELOG.md b/plugins/catalog-react/CHANGELOG.md index 79dfa5e72a..e6d00b2f8f 100644 --- a/plugins/catalog-react/CHANGELOG.md +++ b/plugins/catalog-react/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-catalog-react +## 0.6.4 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/core-app-api@0.1.21 + ## 0.6.3 ### Patch Changes diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 9778163391..c65f9144e5 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-react", "description": "A frontend library that helps other Backstage plugins interact with the catalog", - "version": "0.6.3", + "version": "0.6.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,13 +29,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.5.1", - "@backstage/catalog-model": "^0.9.6", - "@backstage/core-app-api": "^0.1.20", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-app-api": "^0.1.24", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", + "@backstage/integration": "^0.6.10", "@backstage/types": "^0.1.1", "@backstage/version-bridge": "^0.1.0", "@material-ui/core": "^4.12.2", @@ -51,8 +51,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx index 8eba11aeca..f6e9d296df 100644 --- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx @@ -17,16 +17,15 @@ import React from 'react'; import { fireEvent, waitFor } from '@testing-library/react'; import { capitalize } from 'lodash'; -import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { EntityTypePicker } from './EntityTypePicker'; import { MockEntityListContextProvider } from '../../testUtils/providers'; import { catalogApiRef } from '../../api'; import { EntityKindFilter, EntityTypeFilter } from '../../filters'; -import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; -import { renderWithEffects } from '@backstage/test-utils'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { ApiProvider } from '@backstage/core-app-api'; +import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils'; const entities: Entity[] = [ { @@ -61,11 +60,20 @@ const entities: Entity[] = [ }, ]; -const apis = ApiRegistry.with(catalogApiRef, { - getEntities: jest.fn().mockResolvedValue({ items: entities }), -} as unknown as CatalogApi).with(alertApiRef, { - post: jest.fn(), -} as unknown as AlertApi); +const apis = TestApiRegistry.from( + [ + catalogApiRef, + { + getEntities: jest.fn().mockResolvedValue({ items: entities }), + }, + ], + [ + alertApiRef, + { + post: jest.fn(), + }, + ], +); describe('', () => { it('renders available entity types', async () => { diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx index eccf824c2e..2b29bba4fd 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx @@ -24,7 +24,7 @@ import { CatalogClient } from '@backstage/catalog-client'; import { catalogApiRef } from '../../api'; import { entityRouteRef } from '../../routes'; import { screen, waitFor } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import * as state from './useUnregisterEntityDialogState'; import { @@ -32,7 +32,6 @@ import { alertApiRef, DiscoveryApi, } from '@backstage/core-plugin-api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('UnregisterEntityDialog', () => { const discoveryApi: DiscoveryApi = { @@ -49,11 +48,6 @@ describe('UnregisterEntityDialog', () => { }, }; - const apis = ApiRegistry.with( - catalogApiRef, - new CatalogClient({ discoveryApi }), - ).with(alertApiRef, alertApi); - const entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', @@ -68,7 +62,14 @@ describe('UnregisterEntityDialog', () => { }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); const stateSpy = jest.spyOn(state, 'useUnregisterEntityDialogState'); diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx index 8b4f436aed..8d6083f111 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx @@ -31,7 +31,7 @@ import { UseUnregisterEntityDialogState, useUnregisterEntityDialogState, } from './useUnregisterEntityDialogState'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { TestApiProvider } from '@backstage/test-utils'; function defer(): { promise: Promise; resolve: (value: T) => void } { let resolve: (value: T) => void = () => {}; @@ -51,9 +51,9 @@ describe('useUnregisterEntityDialogState', () => { const catalogApi = catalogApiMock as Partial as CatalogApi; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); let entity: Entity; diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx index 4662e58dd2..f6a4c59374 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx @@ -26,9 +26,9 @@ import { MockEntityListContextProvider } from '../../testUtils/providers'; import { EntityTagFilter, UserListFilter } from '../../filters'; import { CatalogApi } from '@backstage/catalog-client'; import { catalogApiRef } from '../../api'; -import { MockStorageApi } from '@backstage/test-utils'; +import { MockStorageApi, TestApiRegistry } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { ConfigApi, configApiRef, @@ -62,12 +62,12 @@ const mockIdentityApi = { getIdToken: async () => undefined, } as Partial; -const apis = ApiRegistry.from([ +const apis = TestApiRegistry.from( [configApiRef, mockConfigApi], [catalogApiRef, mockCatalogApi], [identityApiRef, mockIdentityApi], [storageApiRef, MockStorageApi.create()], -]); +); const mockIsOwnedEntity = (entity: Entity) => entity.metadata.name === 'component-1'; diff --git a/plugins/catalog-react/src/hooks/useEntityKinds.test.tsx b/plugins/catalog-react/src/hooks/useEntityKinds.test.tsx index d801e508a6..cdcac8c194 100644 --- a/plugins/catalog-react/src/hooks/useEntityKinds.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityKinds.test.tsx @@ -15,12 +15,12 @@ */ import React, { PropsWithChildren } from 'react'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { catalogApiRef } from '../api'; import { renderHook } from '@testing-library/react-hooks'; import { useEntityKinds } from './useEntityKinds'; +import { TestApiProvider } from '@backstage/test-utils'; const entities: Entity[] = [ { @@ -59,9 +59,9 @@ const mockCatalogApi: Partial = { const wrapper = ({ children }: PropsWithChildren<{}>) => { return ( - + {children} - + ); }; diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index e448ee73f1..b96f63304a 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -16,7 +16,6 @@ import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { ConfigApi, configApiRef, @@ -24,7 +23,7 @@ import { identityApiRef, storageApiRef, } from '@backstage/core-plugin-api'; -import { MockStorageApi } from '@backstage/test-utils'; +import { MockStorageApi, TestApiProvider } from '@backstage/test-utils'; import { act, renderHook } from '@testing-library/react-hooks'; import qs from 'qs'; import React, { PropsWithChildren } from 'react'; @@ -76,16 +75,6 @@ const mockCatalogApi: Partial = { getEntities: jest.fn().mockImplementation(async () => ({ items: entities })), getEntityByName: async () => undefined, }; -const apis = ApiRegistry.from([ - [configApiRef, mockConfigApi], - [catalogApiRef, mockCatalogApi], - [identityApiRef, mockIdentityApi], - [storageApiRef, MockStorageApi.create()], - [ - starredEntitiesApiRef, - new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }), - ], -]); const wrapper = ({ userFilter, @@ -94,13 +83,26 @@ const wrapper = ({ userFilter?: UserListFilterKind; }>) => { return ( - + - + ); }; diff --git a/plugins/catalog-react/src/hooks/useEntityOwnership.test.tsx b/plugins/catalog-react/src/hooks/useEntityOwnership.test.tsx index 4edad98c12..f01c13839d 100644 --- a/plugins/catalog-react/src/hooks/useEntityOwnership.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityOwnership.test.tsx @@ -21,8 +21,8 @@ import { RELATION_OWNED_BY, UserEntity, } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { IdentityApi, identityApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; import { renderHook } from '@testing-library/react-hooks'; import React from 'react'; import { catalogApiRef } from '../api'; @@ -50,14 +50,14 @@ describe('useEntityOwnership', () => { const catalogApi = mockCatalogApi as unknown as CatalogApi; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} - + ); const ownedEntity: ComponentEntity = { diff --git a/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx index 4f41ef090f..aef876a1b2 100644 --- a/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx +++ b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx @@ -15,9 +15,8 @@ */ import { Entity } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { StorageApi } from '@backstage/core-plugin-api'; -import { MockStorageApi } from '@backstage/test-utils'; +import { MockStorageApi, TestApiProvider } from '@backstage/test-utils'; import { act, renderHook } from '@testing-library/react-hooks'; import React, { PropsWithChildren } from 'react'; import { DefaultStarredEntitiesApi, starredEntitiesApiRef } from '../apis'; @@ -47,14 +46,16 @@ describe('useStarredEntities', () => { beforeEach(() => { mockStorage = MockStorageApi.create(); wrapper = ({ children }: PropsWithChildren<{}>) => ( - {children} - + ); }); diff --git a/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx b/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx index b8577aec0c..8ffc891092 100644 --- a/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx +++ b/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx @@ -15,7 +15,7 @@ */ import { Entity, EntityName } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { TestApiProvider } from '@backstage/test-utils'; import { renderHook } from '@testing-library/react-hooks'; import React, { PropsWithChildren } from 'react'; import Observable from 'zen-observable'; @@ -31,11 +31,9 @@ describe('useStarredEntity', () => { beforeEach(() => { wrapper = ({ children }: PropsWithChildren<{}>) => ( - + {children} - + ); }); diff --git a/plugins/catalog-react/src/routes.ts b/plugins/catalog-react/src/routes.ts index 42f394ef61..f29d52f08a 100644 --- a/plugins/catalog-react/src/routes.ts +++ b/plugins/catalog-react/src/routes.ts @@ -17,22 +17,18 @@ import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { createRouteRef } from '@backstage/core-plugin-api'; -const NoIcon = () => null; - // TODO(Rugvip): Move these route refs back to the catalog plugin once we're all ported to using external routes export const rootRoute = createRouteRef({ - icon: NoIcon, - path: '', - title: 'Catalog', + id: 'catalog', }); + export const catalogRouteRef = rootRoute; export const entityRoute = createRouteRef({ - icon: NoIcon, - path: ':namespace/:kind/:name/*', - title: 'Entity', + id: 'catalog:entity', params: ['namespace', 'kind', 'name'], }); + export const entityRouteRef = entityRoute; // Utility function to get suitable route params for entityRoute, given an diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index 0030f702ec..7fda738d32 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-catalog +## 0.7.3 + +### Patch Changes + +- 38d6df6bb9: Remove the "View Api" icon in the AboutCard, as the information is misleading for some users and is + duplicated in the tabs above. +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/integration-react@0.1.14 + ## 0.7.2 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 9697741dde..6caadb7b42 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog", "description": "The Backstage plugin for browsing the Backstage catalog", - "version": "0.7.2", + "version": "0.7.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,18 +31,19 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", "@backstage/errors": "^0.1.3", - "@backstage/integration-react": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@types/react": "*", + "history": "^5.0.0", "lodash": "^4.17.21", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -51,10 +52,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index d6f13ed22d..afbed3bdc7 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -15,11 +15,7 @@ */ import { RELATION_OWNED_BY } from '@backstage/catalog-model'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/core-app-api'; import { ScmIntegrationsApi, scmIntegrationsApiRef, @@ -30,7 +26,7 @@ import { CatalogApi, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { viewTechDocRouteRef } from '../../routes'; @@ -71,21 +67,25 @@ describe('', () => { }, ], }; - const apis = ApiRegistry.with( - scmIntegrationsApiRef, - ScmIntegrationsApi.fromConfig( - new ConfigReader({ - integrations: {}, - }), - ), - ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -116,28 +116,32 @@ describe('', () => { lifecycle: 'production', }, }; - const apis = ApiRegistry.with( - scmIntegrationsApiRef, - ScmIntegrationsApi.fromConfig( - new ConfigReader({ - integrations: { - github: [ - { - host: 'github.com', - token: '...', - }, - ], - }, - }), - ), - ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -167,28 +171,32 @@ describe('', () => { lifecycle: 'production', }, }; - const apis = ApiRegistry.with( - scmIntegrationsApiRef, - ScmIntegrationsApi.fromConfig( - new ConfigReader({ - integrations: { - github: [ - { - host: 'github.com', - token: '...', - }, - ], - }, - }), - ), - ).with(catalogApiRef, catalogApi); const { getByTitle } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -216,17 +224,21 @@ describe('', () => { lifecycle: 'production', }, }; - const apis = ApiRegistry.with( - scmIntegrationsApiRef, - ScmIntegrationsApi.fromConfig(new ConfigReader({})), - ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -253,17 +265,21 @@ describe('', () => { lifecycle: 'production', }, }; - const apis = ApiRegistry.with( - scmIntegrationsApiRef, - ScmIntegrationsApi.fromConfig(new ConfigReader({})), - ).with(catalogApiRef, catalogApi); const { getByTitle } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -295,17 +311,21 @@ describe('', () => { lifecycle: 'production', }, }; - const apis = ApiRegistry.with( - scmIntegrationsApiRef, - ScmIntegrationsApi.fromConfig(new ConfigReader({})), - ).with(catalogApiRef, catalogApi); const { queryByTitle } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -332,28 +352,32 @@ describe('', () => { lifecycle: 'production', }, }; - const apis = ApiRegistry.with( - scmIntegrationsApiRef, - ScmIntegrationsApi.fromConfig( - new ConfigReader({ - integrations: { - github: [ - { - host: 'github.com', - token: '...', - }, - ], - }, - }), - ), - ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/docs/:namespace/:kind/:name': viewTechDocRouteRef, @@ -381,28 +405,32 @@ describe('', () => { lifecycle: 'production', }, }; - const apis = ApiRegistry.with( - scmIntegrationsApiRef, - ScmIntegrationsApi.fromConfig( - new ConfigReader({ - integrations: { - github: [ - { - host: 'github.com', - token: '...', - }, - ], - }, - }), - ), - ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -429,28 +457,32 @@ describe('', () => { lifecycle: 'production', }, }; - const apis = ApiRegistry.with( - scmIntegrationsApiRef, - ScmIntegrationsApi.fromConfig( - new ConfigReader({ - integrations: { - github: [ - { - host: 'github.com', - token: '...', - }, - ], - }, - }), - ), - ).with(catalogApiRef, catalogApi); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, diff --git a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx index 2730665fb6..b320d6d220 100644 --- a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx +++ b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx @@ -18,13 +18,12 @@ import React from 'react'; import { fireEvent } from '@testing-library/react'; import { Entity } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityKindFilter, MockEntityListContextProvider, } from '@backstage/plugin-catalog-react'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; -import { renderWithEffects } from '@backstage/test-utils'; +import { ApiProvider } from '@backstage/core-app-api'; +import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils'; import { CatalogKindHeader } from './CatalogKindHeader'; const entities: Entity[] = [ @@ -58,9 +57,12 @@ const entities: Entity[] = [ }, ]; -const apis = ApiRegistry.with(catalogApiRef, { - getEntities: jest.fn().mockResolvedValue({ items: entities }), -} as Partial); +const apis = TestApiRegistry.from([ + catalogApiRef, + { + getEntities: jest.fn().mockResolvedValue({ items: entities }), + }, +]); describe('', () => { it('renders available kinds', async () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 6b33b33b08..0a46580c12 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -20,7 +20,6 @@ import { RELATION_MEMBER_OF, RELATION_OWNED_BY, } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { TableColumn, TableProps } from '@backstage/core-components'; import { IdentityApi, @@ -38,6 +37,7 @@ import { mockBreakpoint, MockStorageApi, renderWithEffects, + TestApiProvider, wrapInTestApp, } from '@backstage/test-utils'; import DashboardIcon from '@material-ui/icons/Dashboard'; @@ -129,8 +129,8 @@ describe('CatalogPage', () => { const renderWrapped = (children: React.ReactNode) => renderWithEffects( wrapInTestApp( - { starredEntitiesApiRef, new DefaultStarredEntitiesApi({ storageApi }), ], - ])} + ]} > {children} - , + , { mountedRoutes: { '/create': createComponentRouteRef, diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 822913a385..4c6adcf400 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -19,7 +19,7 @@ import { Entity, VIEW_URL_ANNOTATION, } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { entityRouteRef, DefaultStarredEntitiesApi, @@ -27,7 +27,11 @@ import { starredEntitiesApiRef, UserListFilter, } from '@backstage/plugin-catalog-react'; -import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import { + MockStorageApi, + renderInTestApp, + TestApiRegistry, +} from '@backstage/test-utils'; import { act, fireEvent } from '@testing-library/react'; import * as React from 'react'; import { CatalogTable } from './CatalogTable'; @@ -51,10 +55,10 @@ const entities: Entity[] = [ ]; describe('CatalogTable component', () => { - const mockApis = ApiRegistry.with( + const mockApis = TestApiRegistry.from([ starredEntitiesApiRef, new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }), - ); + ]); beforeEach(() => { window.open = jest.fn(); diff --git a/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx b/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx index 7dd823a8f0..e43104b67d 100644 --- a/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx +++ b/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx @@ -21,28 +21,20 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { DependencyOfComponentsCard } from './DependencyOfComponentsCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByEntity: jest.fn(), - removeEntityByUid: jest.fn(), - } as any; + const getEntities: jest.MockedFunction = jest.fn(); let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); @@ -97,7 +89,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + getEntities.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx index b9fd75def9..b654136d18 100644 --- a/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx +++ b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx @@ -21,28 +21,20 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { DependsOnComponentsCard } from './DependsOnComponentsCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByEntity: jest.fn(), - removeEntityByUid: jest.fn(), - } as any; + const getEntities: jest.MockedFunction = jest.fn(); let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); @@ -97,7 +89,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + getEntities.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx index bee759254c..40685705aa 100644 --- a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx +++ b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx @@ -21,28 +21,20 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { DependsOnResourcesCard } from './DependsOnResourcesCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByEntity: jest.fn(), - removeEntityByUid: jest.fn(), - } as any; + const getEntities: jest.MockedFunction = jest.fn(); let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); @@ -97,7 +89,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + getEntities.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 2216070984..3f3fc804e1 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -16,7 +16,7 @@ import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; import { AsyncEntityProvider, @@ -26,7 +26,11 @@ import { entityRouteRef, starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; -import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import { + MockStorageApi, + renderInTestApp, + TestApiRegistry, +} from '@backstage/test-utils'; import { fireEvent } from '@testing-library/react'; import React from 'react'; import { act } from 'react-dom/test-utils'; @@ -40,12 +44,14 @@ const mockEntity = { }, } as Entity; -const mockApis = ApiRegistry.with(catalogApiRef, {} as CatalogApi) - .with(alertApiRef, {} as AlertApi) - .with( +const mockApis = TestApiRegistry.from( + [catalogApiRef, {} as CatalogApi], + [alertApiRef, {} as AlertApi], + [ starredEntitiesApiRef, new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }), - ); + ], +); describe('EntityLayout', () => { it('renders simplest case', async () => { diff --git a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx index 862ef9504b..f58e6b9b66 100644 --- a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx +++ b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx @@ -20,10 +20,9 @@ import { ORIGIN_LOCATION_ANNOTATION } from '@backstage/catalog-model'; import { CatalogApi } from '@backstage/catalog-client'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { screen, waitFor } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('DeleteEntityDialog', () => { const alertApi: jest.Mocked = { @@ -34,10 +33,6 @@ describe('DeleteEntityDialog', () => { const catalogClient: jest.Mocked = { removeEntityByUid: jest.fn(), } as any; - const apis = ApiRegistry.with(catalogApiRef, catalogClient).with( - alertApiRef, - alertApi, - ); const entity = { apiVersion: 'backstage.io/v1alpha1', @@ -54,7 +49,14 @@ describe('DeleteEntityDialog', () => { }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); afterEach(() => { diff --git a/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx index 9e26184a8d..a8aac405fe 100644 --- a/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx +++ b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx @@ -15,23 +15,16 @@ */ import { - CatalogApi, catalogApiRef, catalogRouteRef, EntityProvider, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import React from 'react'; import { EntityOrphanWarning } from './EntityOrphanWarning'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { - const catalogClient: jest.Mocked = { - removeEntityByUid: jest.fn(), - } as any; - const apis = ApiRegistry.with(catalogApiRef, catalogClient); - it('renders EntityOrphanWarning if the entity is orphan', async () => { const entity = { apiVersion: 'v1', @@ -50,11 +43,20 @@ describe('', () => { }; const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/create': catalogRouteRef, diff --git a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx index c406d0ed3c..fcc4475f6a 100644 --- a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx +++ b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx @@ -21,17 +21,17 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import React from 'react'; import { EntityProcessingErrorsPanel } from './EntityProcessingErrorsPanel'; import { Entity, getEntityName } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; describe('', () => { - const catalogClient: jest.Mocked = { - getEntityAncestors: jest.fn(), - } as any; - const apis = ApiRegistry.with(catalogApiRef, catalogClient); + const getEntityAncestors: jest.MockedFunction< + CatalogApi['getEntityAncestors'] + > = jest.fn(); + const apis = TestApiRegistry.from([catalogApiRef, { getEntityAncestors }]); it('renders EntityProcessErrors if the entity has errors', async () => { const entity: Entity = { @@ -97,7 +97,7 @@ describe('', () => { }, }; - catalogClient.getEntityAncestors.mockResolvedValue({ + getEntityAncestors.mockResolvedValue({ root: getEntityName(entity), items: [{ entity, parents: [] }], }); @@ -198,7 +198,7 @@ describe('', () => { ], }, }; - catalogClient.getEntityAncestors.mockResolvedValue({ + getEntityAncestors.mockResolvedValue({ root: getEntityName(entity), items: [ { entity, parents: [getEntityName(parent)] }, diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 7f8bf35b74..8768e9728e 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -21,17 +21,14 @@ import React from 'react'; import { isKind } from './conditions'; import { EntitySwitch } from './EntitySwitch'; import { featureFlagsApiRef } from '@backstage/core-plugin-api'; -import { - LocalStorageFeatureFlags, - ApiProvider, - ApiRegistry, -} from '@backstage/core-app-api'; +import { LocalStorageFeatureFlags } from '@backstage/core-app-api'; +import { TestApiProvider } from '@backstage/test-utils'; const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); describe('EntitySwitch', () => { diff --git a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx index d484ab026a..45f7561d6e 100644 --- a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx +++ b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx @@ -21,28 +21,20 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { HasComponentsCard } from './HasComponentsCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByEntity: jest.fn(), - removeEntityByUid: jest.fn(), - } as any; + const getEntities: jest.MockedFunction = jest.fn(); let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); @@ -97,7 +89,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + getEntities.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx index 45604ceb56..a01922d988 100644 --- a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx +++ b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx @@ -21,28 +21,20 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { HasResourcesCard } from './HasResourcesCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByEntity: jest.fn(), - removeEntityByUid: jest.fn(), - } as any; + const getEntities: jest.MockedFunction = jest.fn(); let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); @@ -92,7 +84,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + getEntities.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx index 431e757656..e517bd38ae 100644 --- a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx @@ -21,28 +21,20 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { HasSubcomponentsCard } from './HasSubcomponentsCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByEntity: jest.fn(), - removeEntityByUid: jest.fn(), - } as any; + const getEntities: jest.MockedFunction = jest.fn(); let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); @@ -97,7 +89,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + getEntities.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx index 3fcfed6099..050550101f 100644 --- a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx @@ -21,28 +21,20 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { HasSystemsCard } from './HasSystemsCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByEntity: jest.fn(), - removeEntityByUid: jest.fn(), - } as any; + const getEntities: jest.MockedFunction = jest.fn(); let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); @@ -95,7 +87,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + getEntities.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index 9116c0b551..2a0eacf074 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -16,8 +16,6 @@ import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { AsyncEntityProvider, - entityRoute, - rootRoute, useEntity, useEntityFromUrl, } from '@backstage/plugin-catalog-react'; @@ -87,9 +85,9 @@ export const Router = ({ EntityPage?: ComponentType; }) => ( - } /> + } /> diff --git a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx index 2b35d0f3b6..e0ec48fbed 100644 --- a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx +++ b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx @@ -21,10 +21,9 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { Entity, RELATION_PART_OF } from '@backstage/catalog-model'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import React from 'react'; import { SystemDiagramCard } from './SystemDiagramCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { beforeAll(() => { @@ -55,11 +54,11 @@ describe('', () => { }; const { queryByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -114,11 +113,11 @@ describe('', () => { }; const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, @@ -173,11 +172,11 @@ describe('', () => { }; const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, diff --git a/plugins/circleci/CHANGELOG.md b/plugins/circleci/CHANGELOG.md index 78cd6d9711..aa0b5a08a7 100644 --- a/plugins/circleci/CHANGELOG.md +++ b/plugins/circleci/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-circleci +## 0.2.30 + +### Patch Changes + +- 5a46712a7d: Fixed a bug in the `CircleCI` plugin where restarting builds was hard-coded to GitHub rather than introspecting the entity source location. +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.2.29 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.28 ### Patch Changes diff --git a/plugins/circleci/api-report.md b/plugins/circleci/api-report.md index 25d4af0923..bb4334c0f9 100644 --- a/plugins/circleci/api-report.md +++ b/plugins/circleci/api-report.md @@ -16,7 +16,9 @@ import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { GitType } from 'circleci-api'; import { Me } from 'circleci-api'; +import { PathParams } from '@backstage/core-plugin-api'; import { RouteRef } from '@backstage/core-plugin-api'; +import { SubRouteRef } from '@backstage/core-plugin-api'; export { BuildStepAction }; @@ -68,7 +70,7 @@ export const circleCIApiRef: ApiRef; // Warning: (ae-missing-release-tag) "circleCIBuildRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const circleCIBuildRouteRef: RouteRef; +export const circleCIBuildRouteRef: SubRouteRef>; // Warning: (ae-missing-release-tag) "circleCIPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index c57d19ea00..45579ab82b 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-circleci", "description": "A Backstage plugin that integrates towards Circle CI", - "version": "0.2.28", + "version": "0.2.30", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,11 +32,11 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.3", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -52,10 +52,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx index b33e1b40cc..53cbc754cf 100644 --- a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -26,7 +26,7 @@ import { import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; import LaunchIcon from '@material-ui/icons/Launch'; -import { Link as RouterLink, generatePath } from 'react-router-dom'; +import { Link as RouterLink } from 'react-router-dom'; import { durationHumanized, relativeTimeTo } from '../../../../util'; import { circleCIBuildRouteRef } from '../../../../route-refs'; import { @@ -38,6 +38,7 @@ import { Table, TableColumn, } from '@backstage/core-components'; +import { useRouteRef } from '@backstage/core-plugin-api'; export type CITableBuildInfo = { id: string; @@ -144,16 +145,24 @@ const generatedColumns: TableColumn[] = [ field: 'buildName', highlight: true, width: '20%', - render: (row: Partial) => ( - - {row.buildName ? row.buildName : row?.workflow?.name} - - ), + render: (row: Partial) => { + const LinkWrapper = () => { + const routeLink = useRouteRef(circleCIBuildRouteRef); + + return ( + + {row.buildName ? row.buildName : row?.workflow?.name} + + ); + }; + + return ; + }, }, { title: 'Job', diff --git a/plugins/circleci/src/components/Router.tsx b/plugins/circleci/src/components/Router.tsx index 1bc13d6327..5aa8dd9df9 100644 --- a/plugins/circleci/src/components/Router.tsx +++ b/plugins/circleci/src/components/Router.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { Routes, Route } from 'react-router'; -import { circleCIRouteRef, circleCIBuildRouteRef } from '../route-refs'; +import { circleCIBuildRouteRef } from '../route-refs'; import { BuildWithStepsPage } from './BuildWithStepsPage/'; import { BuildsPage } from './BuildsPage'; import { CIRCLECI_ANNOTATION } from '../constants'; @@ -41,9 +41,9 @@ export const Router = (_props: Props) => { return ( - } /> + } /> } /> diff --git a/plugins/circleci/src/route-refs.tsx b/plugins/circleci/src/route-refs.tsx index 197fab4a7b..e8579b9f0d 100644 --- a/plugins/circleci/src/route-refs.tsx +++ b/plugins/circleci/src/route-refs.tsx @@ -14,30 +14,14 @@ * limitations under the License. */ -import React from 'react'; -import { SvgIcon, SvgIconProps } from '@material-ui/core'; -import { createRouteRef } from '@backstage/core-plugin-api'; - -const CircleCIIcon = (props: SvgIconProps) => ( - - - -); +import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; export const circleCIRouteRef = createRouteRef({ - icon: CircleCIIcon, - path: '', - title: 'CircleCI | All builds', + id: 'circle-ci', }); -export const circleCIBuildRouteRef = createRouteRef({ - path: ':buildId', - title: 'CircleCI | Build info', +export const circleCIBuildRouteRef = createSubRouteRef({ + id: 'circle-ci/build', + parent: circleCIRouteRef, + path: '/:buildId', }); diff --git a/plugins/circleci/src/state/useBuilds.ts b/plugins/circleci/src/state/useBuilds.ts index a9869644a3..521a55e922 100644 --- a/plugins/circleci/src/state/useBuilds.ts +++ b/plugins/circleci/src/state/useBuilds.ts @@ -162,7 +162,7 @@ export function useBuilds() { vcs: { owner: owner, repo: repo, - type: GitType.GITHUB, + type: mapVcsType(vcs), }, }); } catch (e) { diff --git a/plugins/cloudbuild/CHANGELOG.md b/plugins/cloudbuild/CHANGELOG.md index dfd17e9908..735f5d2167 100644 --- a/plugins/cloudbuild/CHANGELOG.md +++ b/plugins/cloudbuild/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-cloudbuild +## 0.2.28 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.27 ### Patch Changes diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 6521816f73..3c9e14c9e7 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-cloudbuild", "description": "A Backstage plugin that integrates towards Google Cloud Build", - "version": "0.2.27", + "version": "0.2.28", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,11 +31,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -49,10 +49,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/cloudbuild/src/components/Router.tsx b/plugins/cloudbuild/src/components/Router.tsx index f9b9a8974f..bdeae7e8fd 100644 --- a/plugins/cloudbuild/src/components/Router.tsx +++ b/plugins/cloudbuild/src/components/Router.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Routes, Route } from 'react-router'; -import { rootRouteRef, buildRouteRef } from '../routes'; +import { buildRouteRef } from '../routes'; import { WorkflowRunDetails } from './WorkflowRunDetails'; import { WorkflowRunsTable } from './WorkflowRunsTable'; import { CLOUDBUILD_ANNOTATION } from './useProjectName'; @@ -40,15 +40,11 @@ export const Router = (_props: Props) => { } return ( + } /> } - /> - } /> - ) ); }; diff --git a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.test.tsx b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.test.tsx index ac28c5bedf..0152f9d964 100644 --- a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.test.tsx +++ b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.test.tsx @@ -18,6 +18,7 @@ import { renderInTestApp } from '@backstage/test-utils'; import React from 'react'; import { WorkflowRunsTableView } from './WorkflowRunsTable'; import { WorkflowRun } from '../useWorkflowRuns'; +import { rootRouteRef } from '../../routes'; describe('', () => { let runs: WorkflowRun[] = []; @@ -56,6 +57,7 @@ describe('', () => { runs={runs} total={runs.length} />, + { mountedRoutes: { '/': rootRouteRef } }, ); expect(getByTestId('cell-source')).toHaveAttribute('href', '/run_id_1'); @@ -74,6 +76,7 @@ describe('', () => { runs={runs} total={runs.length} />, + { mountedRoutes: { '/': rootRouteRef } }, ); expect(getByTestId('cell-created')).toHaveTextContent( @@ -94,6 +97,7 @@ describe('', () => { runs={runs} total={runs.length} />, + { mountedRoutes: { '/': rootRouteRef } }, ); const rerunActionElement = getByTestId('action-rerun'); diff --git a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index 0d00347183..d79d481cd4 100644 --- a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Link, Typography, Box, IconButton, Tooltip } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GoogleIcon from '@material-ui/icons/CloudCircle'; -import { Link as RouterLink, generatePath } from 'react-router-dom'; +import { Link as RouterLink } from 'react-router-dom'; import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import SyncIcon from '@material-ui/icons/Sync'; @@ -26,6 +26,7 @@ import { Entity } from '@backstage/catalog-model'; import { buildRouteRef } from '../../routes'; import { DateTime } from 'luxon'; import { Table, TableColumn } from '@backstage/core-components'; +import { useRouteRef } from '@backstage/core-plugin-api'; const generatedColumns: TableColumn[] = [ { @@ -54,15 +55,22 @@ const generatedColumns: TableColumn[] = [ field: 'source', highlight: true, width: '200px', - render: (row: Partial) => ( - - {row.message} - - ), + render: (row: Partial) => { + const LinkWrapper = () => { + const routeLink = useRouteRef(buildRouteRef); + return ( + + {row.message} + + ); + }; + + return ; + }, }, { title: 'Ref', diff --git a/plugins/cloudbuild/src/routes.ts b/plugins/cloudbuild/src/routes.ts index 5e3525c529..3737e6948f 100644 --- a/plugins/cloudbuild/src/routes.ts +++ b/plugins/cloudbuild/src/routes.ts @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createRouteRef } from '@backstage/core-plugin-api'; +import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '', - title: 'Google Cloudbuild', + id: 'cloudbuild', }); -export const buildRouteRef = createRouteRef({ - path: ':id', - title: 'Cloudbuild Run', +export const buildRouteRef = createSubRouteRef({ + id: 'cloudbuild/run', + path: '/:id', + parent: rootRouteRef, }); diff --git a/plugins/code-coverage-backend/CHANGELOG.md b/plugins/code-coverage-backend/CHANGELOG.md index 8e8f9ceac8..387e86bc46 100644 --- a/plugins/code-coverage-backend/CHANGELOG.md +++ b/plugins/code-coverage-backend/CHANGELOG.md @@ -1,5 +1,36 @@ # @backstage/plugin-code-coverage-backend +## 0.1.16 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/backend-common@0.9.12 + +## 0.1.15 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/backend-common@0.9.11 + ## 0.1.14 ### Patch Changes diff --git a/plugins/code-coverage-backend/README.md b/plugins/code-coverage-backend/README.md index 101bee3e3f..4cfc187a3d 100644 --- a/plugins/code-coverage-backend/README.md +++ b/plugins/code-coverage-backend/README.md @@ -31,11 +31,11 @@ POST a Cobertura XML file to `/report` Example: ```json -// curl -X POST -H "Content-Type:text/xml" -d @cobertura.xml "localhost:7000/api/code-coverage/report?entity=component:default/entity-name&coverageType=cobertura" +// curl -X POST -H "Content-Type:text/xml" -d @cobertura.xml "localhost:7007/api/code-coverage/report?entity=component:default/entity-name&coverageType=cobertura" { "links": [ { - "href": "http://localhost:7000/api/code-coverage/report?entity=component:default/entity-name", + "href": "http://localhost:7007/api/code-coverage/report?entity=component:default/entity-name", "rel": "coverage" } ] @@ -49,11 +49,11 @@ POST a JaCoCo XML file to `/report` Example: ```json -// curl -X POST -H "Content-Type:text/xml" -d @jacoco.xml "localhost:7000/api/code-coverage/report?entity=component:default/entity-name&coverageType=jacoco" +// curl -X POST -H "Content-Type:text/xml" -d @jacoco.xml "localhost:7007/api/code-coverage/report?entity=component:default/entity-name&coverageType=jacoco" { "links": [ { - "href": "http://localhost:7000/api/code-coverage/report?entity=component:default/entity-name", + "href": "http://localhost:7007/api/code-coverage/report?entity=component:default/entity-name", "rel": "coverage" } ] @@ -67,7 +67,7 @@ GET `/report` Example: ```json -// curl localhost:7000/api/code-coverage/report?entity=component:default/entity-name +// curl localhost:7007/api/code-coverage/report?entity=component:default/entity-name { "aggregate": { "branch": { @@ -111,7 +111,7 @@ GET `/history` Example ```json -// curl localhost:7000/api/code-coverage/history?entity=component:default/entity-name +// curl localhost:7007/api/code-coverage/history?entity=component:default/entity-name { "entity": { "kind": "Component", diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json index 53d52a9246..83a0acf9c2 100644 --- a/plugins/code-coverage-backend/package.json +++ b/plugins/code-coverage-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-code-coverage-backend", "description": "A Backstage backend plugin that helps you keep track of your code coverage", - "version": "0.1.14", + "version": "0.1.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,14 +20,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/errors": "^0.1.3", - "@backstage/integration": "^0.6.8", + "@backstage/errors": "^0.1.5", + "@backstage/integration": "^0.6.10", "@types/express": "^4.17.6", - "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-xml-bodyparser": "^0.3.0", @@ -37,7 +36,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/express-xml-bodyparser": "^0.3.2", "@types/supertest": "^2.0.8", "msw": "^0.35.0", diff --git a/plugins/code-coverage-backend/src/run.ts b/plugins/code-coverage-backend/src/run.ts index 54d2716290..0a3ed2b7f0 100644 --- a/plugins/code-coverage-backend/src/run.ts +++ b/plugins/code-coverage-backend/src/run.ts @@ -18,7 +18,7 @@ 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 port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/code-coverage-backend/src/service/router.test.ts b/plugins/code-coverage-backend/src/service/router.test.ts index 462606bbab..4e6a66fdd8 100644 --- a/plugins/code-coverage-backend/src/service/router.test.ts +++ b/plugins/code-coverage-backend/src/service/router.test.ts @@ -42,7 +42,7 @@ function createDatabase(): PluginDatabaseManager { const testDiscovery: jest.Mocked = { getBaseUrl: jest .fn() - .mockResolvedValue('http://localhost:7000/api/code-coverage'), + .mockResolvedValue('http://localhost:7007/api/code-coverage'), getExternalBaseUrl: jest.fn(), }; const mockUrlReader = UrlReaders.default({ diff --git a/plugins/code-coverage/CHANGELOG.md b/plugins/code-coverage/CHANGELOG.md index 572e0f7800..b5b413b9d2 100644 --- a/plugins/code-coverage/CHANGELOG.md +++ b/plugins/code-coverage/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-code-coverage +## 0.1.18 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.17 ### Patch Changes diff --git a/plugins/code-coverage/package.json b/plugins/code-coverage/package.json index 0d6e998b69..907a0abcd7 100644 --- a/plugins/code-coverage/package.json +++ b/plugins/code-coverage/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-code-coverage", "description": "A Backstage plugin that helps you keep track of your code coverage", - "version": "0.1.17", + "version": "0.1.18", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,13 +21,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", "@backstage/errors": "^0.1.4", - "@backstage/plugin-catalog-react": "^0.6.3", - "@backstage/theme": "^0.2.13", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -42,10 +42,10 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/code-coverage/src/routes.ts b/plugins/code-coverage/src/routes.ts index bf600ab92e..0f590a53b8 100644 --- a/plugins/code-coverage/src/routes.ts +++ b/plugins/code-coverage/src/routes.ts @@ -16,5 +16,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'code-coverage', + id: 'code-coverage', }); diff --git a/plugins/config-schema/CHANGELOG.md b/plugins/config-schema/CHANGELOG.md index c8372532f6..fc4f8697b8 100644 --- a/plugins/config-schema/CHANGELOG.md +++ b/plugins/config-schema/CHANGELOG.md @@ -1,5 +1,24 @@ # @backstage/plugin-config-schema +## 0.1.14 + +### Patch Changes + +- 9f21236a29: Fixed a missing `await` when throwing server side errors +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.1.13 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.12 ### Patch Changes diff --git a/plugins/config-schema/package.json b/plugins/config-schema/package.json index 0708115270..6b79c5493c 100644 --- a/plugins/config-schema/package.json +++ b/plugins/config-schema/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-config-schema", "description": "A Backstage plugin that lets you browse the configuration schema of your app", - "version": "0.1.12", + "version": "0.1.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,10 +22,10 @@ }, "dependencies": { "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/errors": "^0.1.4", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/errors": "^0.1.5", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -37,10 +37,10 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/config-schema/src/api/StaticSchemaLoader.ts b/plugins/config-schema/src/api/StaticSchemaLoader.ts index 48a125f38e..71eae63376 100644 --- a/plugins/config-schema/src/api/StaticSchemaLoader.ts +++ b/plugins/config-schema/src/api/StaticSchemaLoader.ts @@ -49,7 +49,7 @@ export class StaticSchemaLoader implements ConfigSchemaApi { return undefined; } - throw ResponseError.fromResponse(res); + throw await ResponseError.fromResponse(res); } return await res.json(); diff --git a/plugins/config-schema/src/routes.ts b/plugins/config-schema/src/routes.ts index ebbbdd129c..043301bca1 100644 --- a/plugins/config-schema/src/routes.ts +++ b/plugins/config-schema/src/routes.ts @@ -16,5 +16,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'config-schema', + id: 'config-schema', }); diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index 8a7b07af3b..c43d7c0612 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,24 @@ # @backstage/plugin-cost-insights +## 0.11.12 + +### Patch Changes + +- 950b36393c: Supply featureFlags using featureFlag config option. +- Updated dependencies + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.11.11 + +### Patch Changes + +- 4576f82858: Fixed generation of sample data in the example Cost Insights client +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.11.10 ### Patch Changes diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 064b24bd0c..6a2d582d63 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-cost-insights", "description": "A Backstage plugin that helps you keep track of your cloud spend", - "version": "0.11.10", + "version": "0.11.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ }, "dependencies": { "@backstage/config": "^0.1.10", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -55,10 +55,10 @@ "yup": "^0.32.9" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx index 4a38af3dbc..e29bb78f2f 100644 --- a/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx +++ b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx @@ -15,10 +15,10 @@ */ import { CostInsightsHeader } from './CostInsightsHeader'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import React from 'react'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { IdentityApi, identityApiRef } from '@backstage/core-plugin-api'; describe('', () => { @@ -29,7 +29,7 @@ describe('', () => { }), }; - const apis = ApiRegistry.from([[identityApiRef, identityApi]]); + const apis = TestApiRegistry.from([identityApiRef, identityApi]); it('Shows nothing to do when no alerts exist', async () => { const rendered = await renderInTestApp( diff --git a/plugins/cost-insights/src/plugin.ts b/plugins/cost-insights/src/plugin.ts index cf54dbd957..a422098e08 100644 --- a/plugins/cost-insights/src/plugin.ts +++ b/plugins/cost-insights/src/plugin.ts @@ -21,25 +21,20 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/cost-insights', - title: 'Cost Insights', + id: 'cost-insights', }); export const projectGrowthAlertRef = createRouteRef({ - path: '/cost-insights/investigating-growth', - title: 'Investigating Growth', + id: 'cost-insights:investigating-growth', }); export const unlabeledDataflowAlertRef = createRouteRef({ - path: '/cost-insights/labeling-jobs', - title: 'Labeling Dataflow Jobs', + id: 'cost-insights:labeling-jobs', }); export const costInsightsPlugin = createPlugin({ id: 'cost-insights', - register({ featureFlags }) { - featureFlags.register('cost-insights-currencies'); - }, + featureFlags: [{ name: 'cost-insights-currencies' }], routes: { root: rootRouteRef, growthAlerts: projectGrowthAlertRef, diff --git a/plugins/cost-insights/src/testUtils/providers.tsx b/plugins/cost-insights/src/testUtils/providers.tsx index 75450c0bb7..09d2feb71b 100644 --- a/plugins/cost-insights/src/testUtils/providers.tsx +++ b/plugins/cost-insights/src/testUtils/providers.tsx @@ -30,8 +30,9 @@ import { Group, Duration } from '../types'; // TODO(Rugvip): Could be good to have a clear place to put test utils that is linted accordingly // eslint-disable-next-line import/no-extraneous-dependencies -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { IdentityApi, identityApiRef } from '@backstage/core-plugin-api'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { TestApiProvider } from '@backstage/test-utils'; type PartialPropsWithChildren = PropsWithChildren>; @@ -199,16 +200,17 @@ export const MockCostInsightsApiProvider = ({ getUserGroups: jest.fn(), }; - // TODO: defaultConfigApiRef: ConfigApiRef - - const defaultContext = ApiRegistry.from([ - [identityApiRef, { ...defaultIdentityApi, ...context.identityApi }], - [ - costInsightsApiRef, - { ...defaultCostInsightsApi, ...context.costInsightsApi }, - ], - // [configApiRef, { ...defaultConfigApiRef, ...context.configApiRef }] - ]); - - return {children}; + return ( + + {children} + + ); }; diff --git a/plugins/explore-react/CHANGELOG.md b/plugins/explore-react/CHANGELOG.md index 4af81f6d8b..0560527f80 100644 --- a/plugins/explore-react/CHANGELOG.md +++ b/plugins/explore-react/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/plugin-explore-react +## 0.0.7 + +### Patch Changes + +- Updated dependencies + - @backstage/core-plugin-api@0.2.0 + ## 0.0.6 ### Patch Changes diff --git a/plugins/explore-react/package.json b/plugins/explore-react/package.json index 1dfd24dff8..00e5e87918 100644 --- a/plugins/explore-react/package.json +++ b/plugins/explore-react/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-explore-react", "description": "A frontend library for Backstage plugins that want to interact with the explore plugin", - "version": "0.0.6", + "version": "0.0.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,12 +29,12 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/core-plugin-api": "^0.1.11" + "@backstage/core-plugin-api": "^0.2.2" }, "devDependencies": { - "@backstage/cli": "^0.8.0", - "@backstage/dev-utils": "^0.2.0", - "@backstage/test-utils": "^0.1.14", + "@backstage/cli": "^0.10.0", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.22", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/explore/CHANGELOG.md b/plugins/explore/CHANGELOG.md index f051691dc5..73117d7f32 100644 --- a/plugins/explore/CHANGELOG.md +++ b/plugins/explore/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-explore +## 0.3.21 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/plugin-explore-react@0.0.7 + ## 0.3.20 ### Patch Changes diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 30890c7176..b4e08a526f 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-explore", "description": "A Backstage plugin for building an exploration page of your software ecosystem", - "version": "0.3.20", + "version": "0.3.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,12 +31,12 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/plugin-explore-react": "^0.0.6", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/plugin-explore-react": "^0.0.7", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -49,10 +49,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx index 8927cf088b..d10c262357 100644 --- a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx +++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx @@ -15,11 +15,10 @@ */ import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor, getByText } from '@testing-library/react'; import React from 'react'; import { DefaultExplorePage } from './DefaultExplorePage'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const catalogApi: jest.Mocked = { @@ -36,9 +35,9 @@ describe('', () => { }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); beforeEach(() => { diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx index d78fc0428d..46a8f1adf2 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -16,12 +16,11 @@ import { DomainEntity } from '@backstage/catalog-model'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { catalogEntityRouteRef } from '../../routes'; import { DomainExplorerContent } from './DomainExplorerContent'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const catalogApi: jest.Mocked = { @@ -38,9 +37,9 @@ describe('', () => { }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); const mountedRoutes = { diff --git a/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx b/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx index 80944628da..7574c685a4 100644 --- a/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx +++ b/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx @@ -14,16 +14,12 @@ * limitations under the License. */ -import { - ApiProvider, - ApiRegistry, - FeatureFlagged, -} from '@backstage/core-app-api'; +import { FeatureFlagged } from '@backstage/core-app-api'; import { FeatureFlagsApi, featureFlagsApiRef, } from '@backstage/core-plugin-api'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ExploreLayout } from './ExploreLayout'; @@ -35,11 +31,11 @@ const featureFlagsApi: jest.Mocked = { registerFlag: jest.fn(), }; -const mockApis = ApiRegistry.with(featureFlagsApiRef, featureFlagsApi); - describe('', () => { const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); afterEach(() => { diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.test.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.test.tsx index 66f54900c9..7c57de84bc 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.test.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.test.tsx @@ -20,10 +20,9 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { Entity } from '@backstage/catalog-model'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import React from 'react'; import { GroupsDiagram } from './GroupsDiagram'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { beforeAll(() => { @@ -57,9 +56,9 @@ describe('', () => { }; const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx index f0815e0116..5d3f7c358b 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx @@ -16,11 +16,10 @@ import { Entity } from '@backstage/catalog-model'; import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { GroupsExplorerContent } from '../GroupsExplorerContent'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const catalogApi: jest.Mocked = { @@ -37,9 +36,9 @@ describe('', () => { }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); const mountedRoutes = { diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx index 8accf8fd30..da35c9fa39 100644 --- a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx @@ -18,13 +18,12 @@ import { ExploreTool, exploreToolsConfigRef, } from '@backstage/plugin-explore-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ToolExplorerContent } from './ToolExplorerContent'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const exploreToolsConfigApi: jest.Mocked = { @@ -33,11 +32,9 @@ describe('', () => { const Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} - + ); diff --git a/plugins/explore/src/routes.ts b/plugins/explore/src/routes.ts index 783d334749..d3a94ea4cd 100644 --- a/plugins/explore/src/routes.ts +++ b/plugins/explore/src/routes.ts @@ -19,11 +19,8 @@ import { createRouteRef, } from '@backstage/core-plugin-api'; -const NoIcon = () => null; - export const exploreRouteRef = createRouteRef({ - icon: NoIcon, - title: 'Explore', + id: 'explore', }); export const catalogEntityRouteRef = createExternalRouteRef({ diff --git a/plugins/firehydrant/CHANGELOG.md b/plugins/firehydrant/CHANGELOG.md index 2343eb1c2e..b137ed1f73 100644 --- a/plugins/firehydrant/CHANGELOG.md +++ b/plugins/firehydrant/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-firehydrant +## 0.1.8 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.7 ### Patch Changes diff --git a/plugins/firehydrant/package.json b/plugins/firehydrant/package.json index 4e3fbdd3c8..2b3bf05b1e 100644 --- a/plugins/firehydrant/package.json +++ b/plugins/firehydrant/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-firehydrant", "description": "A Backstage plugin that integrates towards FireHydrant", - "version": "0.1.7", + "version": "0.1.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,10 +22,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -35,10 +35,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx index 1123f014da..20dbb77e86 100644 --- a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx +++ b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx @@ -14,19 +14,19 @@ * limitations under the License. */ import React from 'react'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { fireHydrantApiRef } from '../../api'; import { screen } from '@testing-library/react'; import { ServiceDetailsCard } from './ServiceDetailsCard'; import { Service, Incident } from '../types'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; const mockFireHydrantApi = { - getServiceDetails: () => {}, - getServiceAnalytics: () => {}, + getServiceDetails: jest.fn(), + getServiceAnalytics: jest.fn(), }; -const apis = ApiRegistry.from([[fireHydrantApiRef, mockFireHydrantApi]]); +const apis = TestApiRegistry.from([fireHydrantApiRef, mockFireHydrantApi]); jest.mock('@backstage/plugin-catalog-react', () => ({ useEntity: () => { diff --git a/plugins/firehydrant/src/routes.ts b/plugins/firehydrant/src/routes.ts index eb2f8b6375..e1e3831f95 100644 --- a/plugins/firehydrant/src/routes.ts +++ b/plugins/firehydrant/src/routes.ts @@ -16,5 +16,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'firehydrant', + id: 'firehydrant', }); diff --git a/plugins/fossa/CHANGELOG.md b/plugins/fossa/CHANGELOG.md index f422dfbe41..e0ad30ef69 100644 --- a/plugins/fossa/CHANGELOG.md +++ b/plugins/fossa/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/plugin-fossa +## 0.2.23 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.2.22 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.21 ### Patch Changes diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 94528a3126..7d56fdc333 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-fossa", "description": "A Backstage plugin that integrates towards FOSSA", - "version": "0.2.21", + "version": "0.2.23", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,12 +32,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", "@backstage/errors": "^0.1.3", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -49,16 +49,15 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", - "cross-fetch": "^3.0.6", "msw": "^0.35.0" }, "files": [ diff --git a/plugins/fossa/src/components/FossaCard/FossaCard.test.tsx b/plugins/fossa/src/components/FossaCard/FossaCard.test.tsx index 79b401e4a8..046e3e9860 100644 --- a/plugins/fossa/src/components/FossaCard/FossaCard.test.tsx +++ b/plugins/fossa/src/components/FossaCard/FossaCard.test.tsx @@ -16,11 +16,10 @@ import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import React from 'react'; import { FossaApi, fossaApiRef } from '../../api'; import { FossaCard } from './FossaCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const fossaApi: jest.Mocked = { @@ -30,10 +29,10 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(fossaApiRef, fossaApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx index 778f5ee9c6..421ff4431f 100644 --- a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx +++ b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx @@ -20,11 +20,10 @@ import { catalogApiRef, entityRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import React from 'react'; import { FossaApi, fossaApiRef } from '../../api'; import { FossaPage } from './FossaPage'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('', () => { const catalogApi: jest.Mocked = { @@ -46,13 +45,15 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(fossaApiRef, fossaApi).with( - catalogApiRef, - catalogApi, - ); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/gcp-projects/CHANGELOG.md b/plugins/gcp-projects/CHANGELOG.md index 28c40f6221..ac8dd570a6 100644 --- a/plugins/gcp-projects/CHANGELOG.md +++ b/plugins/gcp-projects/CHANGELOG.md @@ -1,5 +1,30 @@ # @backstage/plugin-gcp-projects +## 0.3.9 + +### Patch Changes + +- 741bcb168e: UI updates to GCP-projects plugin + + Adds the following to the project list page: + + - pagination + - filtering + - sorting + - rows per page + - show/hide columns + + Makes breadcrumb a link back to project list for the project details and new project views. + + In project list page, updates New project button to use RouterLink instead of `href` to avoid login prompt. + + In project details view, links to project details and logs now work, clicking on these will open the project or logs in GCP in new tab. + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.8 ### Patch Changes diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 471b9d80f1..a3a53b317e 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-gcp-projects", "description": "A Backstage plugin that helps you manage projects in GCP", - "version": "0.3.8", + "version": "0.3.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -43,10 +43,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/gcp-projects/src/routes.ts b/plugins/gcp-projects/src/routes.ts index 2d302a178d..f527393f5a 100644 --- a/plugins/gcp-projects/src/routes.ts +++ b/plugins/gcp-projects/src/routes.ts @@ -17,14 +17,11 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/gcp-projects', - title: 'GCP Projects', + id: 'gcp-projects', }); export const projectRouteRef = createRouteRef({ - path: '/gcp-projects/project', - title: 'GCP Project Page', + id: 'gcp-projects:project', }); export const newProjectRouteRef = createRouteRef({ - path: '/gcp-projects/new', - title: 'GCP Project Page', + id: 'gcp-projects:new', }); diff --git a/plugins/git-release-manager/CHANGELOG.md b/plugins/git-release-manager/CHANGELOG.md index 72bcf87b2a..1c627ab086 100644 --- a/plugins/git-release-manager/CHANGELOG.md +++ b/plugins/git-release-manager/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-git-release-manager +## 0.3.3 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.2 ### Patch Changes diff --git a/plugins/git-release-manager/package.json b/plugins/git-release-manager/package.json index b1b2c11732..c5ef82ac16 100644 --- a/plugins/git-release-manager/package.json +++ b/plugins/git-release-manager/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-git-release-manager", "description": "A Backstage plugin that helps you manage releases in git", - "version": "0.3.2", + "version": "0.3.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/integration": "^0.6.8", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/integration": "^0.6.10", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -39,10 +39,10 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/git-release-manager/src/routes.ts b/plugins/git-release-manager/src/routes.ts index 01e4cdf7ff..c4b157892a 100644 --- a/plugins/git-release-manager/src/routes.ts +++ b/plugins/git-release-manager/src/routes.ts @@ -17,5 +17,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'git-release-manager', + id: 'git-release-manager', }); diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index c8fce8e501..7992f201cf 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,38 @@ # @backstage/plugin-github-actions +## 0.4.25 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.4.24 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.4.23 ### Patch Changes diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md index d027368c8b..082267d424 100644 --- a/plugins/github-actions/README.md +++ b/plugins/github-actions/README.md @@ -11,7 +11,7 @@ TBD ### Generic Requirements 1. Provide OAuth credentials: - 1. [Create an OAuth App](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/) with the callback URL set to `http://localhost:7000/api/auth/github`. + 1. [Create an OAuth App](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/) with the callback URL set to `http://localhost:7007/api/auth/github`. 2. Take the Client ID and Client Secret from the newly created app's settings page and put them into `AUTH_GITHUB_CLIENT_ID` and `AUTH_GITHUB_CLIENT_SECRET` environment variables. 2. Annotate your component with a correct GitHub Actions repository and owner: diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 1a4079a5ca..b0fa469a50 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-github-actions", "description": "A Backstage plugin that integrates towards GitHub Actions", - "version": "0.4.23", + "version": "0.4.25", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,12 +33,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/integration": "^0.6.8", - "@backstage/plugin-catalog-react": "^0.6.3", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/integration": "^0.6.10", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -52,10 +52,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx index 4a4452be5c..0f9573e051 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx @@ -24,16 +24,13 @@ import { useWorkflowRuns } from '../useWorkflowRuns'; import type { Props as RecentWorkflowRunsCardProps } from './RecentWorkflowRunsCard'; import { RecentWorkflowRunsCard } from './RecentWorkflowRunsCard'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/core-app-api'; import { errorApiRef, configApiRef, ConfigApi, } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; jest.mock('../useWorkflowRuns', () => ({ useWorkflowRuns: jest.fn(), @@ -82,16 +79,16 @@ describe('', () => { render( - - + , ); diff --git a/plugins/github-actions/src/components/Router.tsx b/plugins/github-actions/src/components/Router.tsx index cbf29d1d9d..ea07213c45 100644 --- a/plugins/github-actions/src/components/Router.tsx +++ b/plugins/github-actions/src/components/Router.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Routes, Route } from 'react-router'; -import { rootRouteRef, buildRouteRef } from '../routes'; +import { buildRouteRef } from '../routes'; import { WorkflowRunDetails } from './WorkflowRunDetails'; import { WorkflowRunsTable } from './WorkflowRunsTable'; import { GITHUB_ACTIONS_ANNOTATION } from './useProjectName'; @@ -41,12 +41,9 @@ export const Router = (_props: Props) => { } return ( + } /> } - /> - } /> ) diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index 51f26bf5d7..2506613e0c 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -24,7 +24,7 @@ import { } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; -import { Link as RouterLink, generatePath } from 'react-router-dom'; +import { Link as RouterLink } from 'react-router-dom'; import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import SyncIcon from '@material-ui/icons/Sync'; @@ -34,7 +34,7 @@ import { Entity } from '@backstage/catalog-model'; import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { EmptyState, Table, TableColumn } from '@backstage/core-components'; -import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; const generatedColumns: TableColumn[] = [ { @@ -47,14 +47,18 @@ const generatedColumns: TableColumn[] = [ title: 'Message', field: 'message', highlight: true, - render: (row: Partial) => ( - - {row.message} - - ), + render: (row: Partial) => { + const LinkWrapper = () => { + const routeLink = useRouteRef(buildRouteRef); + return ( + + {row.message} + + ); + }; + + return ; + }, }, { title: 'Source', diff --git a/plugins/github-actions/src/routes.ts b/plugins/github-actions/src/routes.ts index 6e1d8a885a..8e854fad45 100644 --- a/plugins/github-actions/src/routes.ts +++ b/plugins/github-actions/src/routes.ts @@ -14,15 +14,14 @@ * limitations under the License. */ -import { createRouteRef } from '@backstage/core-plugin-api'; +import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '', - title: 'GitHub Actions', + id: 'github-actions', }); -export const buildRouteRef = createRouteRef({ - path: ':id', - params: ['id'], - title: 'GitHub Actions Workflow Run', +export const buildRouteRef = createSubRouteRef({ + id: 'github-actions/build', + path: '/:id', + parent: rootRouteRef, }); diff --git a/plugins/github-deployments/CHANGELOG.md b/plugins/github-deployments/CHANGELOG.md index 5259ea11b2..701b167135 100644 --- a/plugins/github-deployments/CHANGELOG.md +++ b/plugins/github-deployments/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-github-deployments +## 0.1.21 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/integration-react@0.1.14 + ## 0.1.20 ### Patch Changes diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index 78061951f5..a1dad4ecca 100644 --- a/plugins/github-deployments/package.json +++ b/plugins/github-deployments/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-github-deployments", "description": "A Backstage plugin that integrates towards GitHub Deployments", - "version": "0.1.20", + "version": "0.1.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,14 +21,14 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", "@backstage/errors": "^0.1.3", - "@backstage/integration": "^0.6.8", - "@backstage/integration-react": "^0.1.12", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/integration": "^0.6.10", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -39,10 +39,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx index 08eeb4a3ce..dba5bbfe1c 100644 --- a/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx +++ b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx @@ -19,6 +19,7 @@ import { fireEvent } from '@testing-library/react'; import { setupRequestMockHandlers, renderInTestApp, + TestApiRegistry, } from '@backstage/test-utils'; import { GithubDeployment, @@ -42,11 +43,7 @@ import { Entity } from '@backstage/catalog-model'; import { GithubDeploymentsTable } from './GithubDeploymentsTable'; import { Box } from '@material-ui/core'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { errorApiRef, configApiRef, @@ -95,14 +92,14 @@ const githubAuthApi: OAuthApi = { getAccessToken: async _ => 'access_token', }; -const apis = ApiRegistry.from([ +const apis = TestApiRegistry.from( [configApiRef, configApi], [errorApiRef, errorApiMock], [ githubDeploymentsApiRef, new GithubDeploymentsApiClient({ scmIntegrationsApi, githubAuthApi }), ], -]); +); const assertFetchedData = async () => { const rendered = await renderInTestApp( diff --git a/plugins/gitops-profiles/CHANGELOG.md b/plugins/gitops-profiles/CHANGELOG.md index 08f256cdb5..42f3533d31 100644 --- a/plugins/gitops-profiles/CHANGELOG.md +++ b/plugins/gitops-profiles/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-gitops-profiles +## 0.3.9 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.8 ### Patch Changes diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index c290599c2c..6b211f59ed 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-gitops-profiles", "description": "A Backstage plugin that helps you manage GitOps profiles", - "version": "0.3.8", + "version": "0.3.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -44,10 +44,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx index 8dba498507..e158bcf6ad 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import React from 'react'; @@ -23,7 +23,6 @@ import ProfileCatalog from './ProfileCatalog'; import { ApiProvider, - ApiRegistry, GithubAuth, OAuthRequestManager, UrlPatternDiscovery, @@ -34,7 +33,7 @@ import { githubAuthApiRef } from '@backstage/core-plugin-api'; describe('ProfileCatalog', () => { it('should render', async () => { const oauthRequestApi = new OAuthRequestManager(); - const apis = ApiRegistry.from([ + const apis = TestApiRegistry.from( [gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')], [ githubAuthApiRef, @@ -45,7 +44,7 @@ describe('ProfileCatalog', () => { oauthRequestApi, }), ], - ]); + ); const { getByText } = await renderInTestApp( diff --git a/plugins/gitops-profiles/src/routes.ts b/plugins/gitops-profiles/src/routes.ts index d57edcca2c..b79e5686d2 100644 --- a/plugins/gitops-profiles/src/routes.ts +++ b/plugins/gitops-profiles/src/routes.ts @@ -16,23 +16,15 @@ import { createRouteRef } from '@backstage/core-plugin-api'; -const NoIcon = () => null; - export const gitOpsClusterListRoute = createRouteRef({ - icon: NoIcon, - path: '/gitops-clusters', - title: 'GitOps Clusters', + id: 'gitops-clusters', }); export const gitOpsClusterDetailsRoute = createRouteRef({ - icon: NoIcon, - path: '/gitops-cluster/:owner/:repo', - title: 'GitOps Cluster details', + id: 'gitops-cluster:details', params: ['owner', 'repo'], }); export const gitOpsClusterCreateRoute = createRouteRef({ - icon: NoIcon, - path: '/gitops-cluster-create', - title: 'GitOps Cluster create', + id: 'gitops-cluster:create', }); diff --git a/plugins/graphiql/CHANGELOG.md b/plugins/graphiql/CHANGELOG.md index aa58dbd332..4fbf6b0dab 100644 --- a/plugins/graphiql/CHANGELOG.md +++ b/plugins/graphiql/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/plugin-graphiql +## 0.2.22 + +### Patch Changes + +- cd398cd4ab: Letting GraphiQL use headers +- Updated dependencies + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.2.21 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.20 ### Patch Changes diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index df1ad6f887..49c4d9952c 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.20", + "version": "0.2.22", "private": false, "publishConfig": { "access": "public", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -44,10 +44,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx index b4321343a1..2bd173f720 100644 --- a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx +++ b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx @@ -73,7 +73,12 @@ export const GraphiQLBrowser = ({ endpoints }: GraphiQLBrowserProps) => {
- +
diff --git a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx index 4e426d4364..ae84b549ec 100644 --- a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx +++ b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.test.tsx @@ -19,14 +19,10 @@ import { GraphiQLPage } from './GraphiQLPage'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { act } from 'react-dom/test-utils'; -import { renderWithEffects } from '@backstage/test-utils'; +import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; import { GraphQLBrowseApi, graphQlBrowseApiRef } from '../../lib/api'; import { configApiRef } from '@backstage/core-plugin-api'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/core-app-api'; jest.mock('../GraphiQLBrowser', () => ({ GraphiQLBrowser: () => '', @@ -43,17 +39,17 @@ describe('GraphiQLPage', () => { }; const rendered = await renderWithEffects( - , - , + , ); act(() => { jest.advanceTimersByTime(250); @@ -71,16 +67,16 @@ describe('GraphiQLPage', () => { }; const rendered = await renderWithEffects( - - , + , ); rendered.getByText('GraphiQL'); @@ -95,16 +91,16 @@ describe('GraphiQLPage', () => { }; const rendered = await renderWithEffects( - - , + , ); rendered.getByText('GraphiQL'); diff --git a/plugins/graphql-backend/package.json b/plugins/graphql-backend/package.json index 9d90dd8782..5b505ee828 100644 --- a/plugins/graphql-backend/package.json +++ b/plugins/graphql-backend/package.json @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.8", - "@backstage/plugin-catalog-graphql": "^0.2.12", + "@backstage/plugin-catalog-graphql": "^0.2.14", "@graphql-modules/core": "^0.7.17", "@types/express": "^4.17.6", "apollo-server": "^2.16.1", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.35.0", diff --git a/plugins/home/CHANGELOG.md b/plugins/home/CHANGELOG.md index 06beb827a6..09e379a625 100644 --- a/plugins/home/CHANGELOG.md +++ b/plugins/home/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-home +## 0.4.6 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.4.5 ### Patch Changes diff --git a/plugins/home/package.json b/plugins/home/package.json index 8020d7af03..97ec5fdfc2 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-home", "description": "A Backstage plugin that helps you build a home page", - "version": "0.4.5", + "version": "0.4.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -35,10 +35,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/home/src/routes.ts b/plugins/home/src/routes.ts index 2c641d8433..7b6b47215b 100644 --- a/plugins/home/src/routes.ts +++ b/plugins/home/src/routes.ts @@ -16,5 +16,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'home', + id: 'home', }); diff --git a/plugins/ilert/CHANGELOG.md b/plugins/ilert/CHANGELOG.md index f43f2add09..1bff4b22ef 100644 --- a/plugins/ilert/CHANGELOG.md +++ b/plugins/ilert/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-ilert +## 0.1.17 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.16 ### Patch Changes diff --git a/plugins/ilert/package.json b/plugins/ilert/package.json index 69c4025181..2e477b2f06 100644 --- a/plugins/ilert/package.json +++ b/plugins/ilert/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-ilert", "description": "A Backstage plugin that integrates towards iLert", - "version": "0.1.16", + "version": "0.1.17", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,12 +21,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", "@backstage/errors": "^0.1.3", - "@backstage/plugin-catalog-react": "^0.6.3", - "@backstage/theme": "^0.2.13", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@date-io/luxon": "1.x", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -39,10 +39,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/ilert/src/route-refs.tsx b/plugins/ilert/src/route-refs.tsx index 97d11df98a..8a3d53001d 100644 --- a/plugins/ilert/src/route-refs.tsx +++ b/plugins/ilert/src/route-refs.tsx @@ -14,11 +14,8 @@ * limitations under the License. */ -import ILertIcon from './assets/ilert.icon.svg'; import { createRouteRef } from '@backstage/core-plugin-api'; export const iLertRouteRef = createRouteRef({ - icon: ILertIcon, - path: '/ilert', - title: 'iLert', + id: 'ilert', }); diff --git a/plugins/jenkins-backend/CHANGELOG.md b/plugins/jenkins-backend/CHANGELOG.md index 48f4461f7f..19d743b250 100644 --- a/plugins/jenkins-backend/CHANGELOG.md +++ b/plugins/jenkins-backend/CHANGELOG.md @@ -1,5 +1,34 @@ # @backstage/plugin-jenkins-backend +## 0.1.9 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/backend-common@0.9.12 + +## 0.1.8 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/backend-common@0.9.11 + ## 0.1.7 ### Patch Changes diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json index 7049fc0a32..db4298e6c2 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-jenkins-backend", "description": "A Backstage backend plugin that integrates towards Jenkins", - "version": "0.1.7", + "version": "0.1.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,12 +22,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.9", - "@backstage/catalog-client": "^0.5.1", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", "@types/express": "^4.17.6", - "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", "jenkins": "^0.28.1", @@ -35,7 +34,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.2", + "@backstage/cli": "^0.10.0", "@types/jenkins": "^0.23.1", "@types/supertest": "^2.0.8", "msw": "^0.35.0", diff --git a/plugins/jenkins-backend/src/run.ts b/plugins/jenkins-backend/src/run.ts index b96989e4b8..068a2b9d5f 100644 --- a/plugins/jenkins-backend/src/run.ts +++ b/plugins/jenkins-backend/src/run.ts @@ -18,7 +18,7 @@ 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 port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index 84db4728bc..1daba68a1d 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/plugin-jenkins +## 0.5.13 + +### Patch Changes + +- ad433b346f: Fix Jenkins project table pagination. +- Updated dependencies + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.5.12 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.5.11 ### Patch Changes diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 80d8b40322..e879d13a88 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-jenkins", "description": "A Backstage plugin that integrates towards Jenkins", - "version": "0.5.11", + "version": "0.5.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,11 +32,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -48,10 +48,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index ee17df8533..b1eaf2c24c 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -17,12 +17,13 @@ import React from 'react'; import { Box, IconButton, Link, Typography, Tooltip } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import JenkinsLogo from '../../../../assets/JenkinsLogo.svg'; -import { generatePath, Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink } from 'react-router-dom'; import { JenkinsRunStatus } from '../Status'; import { useBuilds } from '../../../useBuilds'; import { buildRouteRef } from '../../../../plugin'; import { Table, TableColumn } from '@backstage/core-components'; import { Project } from '../../../../api/JenkinsApi'; +import { useRouteRef } from '@backstage/core-plugin-api'; const FailCount = ({ count }: { count: number }): JSX.Element | null => { if (count !== 0) { @@ -91,28 +92,33 @@ const generatedColumns: TableColumn[] = [ field: 'fullName', highlight: true, render: (row: Partial) => { - if (!row.fullName || !row.lastBuild?.number) { - return ( - <> - {row.fullName || - row.fullDisplayName || - row.displayName || - 'Unknown'} - - ); - } + const LinkWrapper = () => { + const routeLink = useRouteRef(buildRouteRef); + if (!row.fullName || !row.lastBuild?.number) { + return ( + <> + {row.fullName || + row.fullDisplayName || + row.displayName || + 'Unknown'} + + ); + } - return ( - - {row.fullDisplayName} - - ); + return ( + + {row.fullDisplayName} + + ); + }; + + return ; }, }, { @@ -199,6 +205,10 @@ export const CITableView = ({ onChangePageSize, total, }: Props) => { + const projectsInPage = projects?.slice( + page * pageSize, + Math.min(projects.length, (page + 1) * pageSize), + ); return (
retry(), }, ]} - data={projects ?? []} + data={projectsInPage ?? []} onPageChange={onChangePage} onRowsPerPageChange={onChangePageSize} title={ diff --git a/plugins/jenkins/src/components/Cards/Cards.test.tsx b/plugins/jenkins/src/components/Cards/Cards.test.tsx index 2508461710..0af91da179 100644 --- a/plugins/jenkins/src/components/Cards/Cards.test.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.test.tsx @@ -15,11 +15,10 @@ */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { LatestRunCard } from './Cards'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { JenkinsApi, jenkinsApiRef } from '../../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { Project } from '../../api/JenkinsApi'; describe('', () => { @@ -41,14 +40,12 @@ describe('', () => { }; it('should show success status of latest build', async () => { - const apis = ApiRegistry.from([[jenkinsApiRef, jenkinsApi]]); - const { getByText } = await renderInTestApp( - + - , + , ); expect(getByText('Completed')).toBeInTheDocument(); @@ -59,14 +56,12 @@ describe('', () => { getProjects: () => Promise.reject(new Error('Unauthorized')), }; - const apis = ApiRegistry.from([[jenkinsApiRef, jenkinsApiWithError]]); - const { getByText } = await renderInTestApp( - + - , + , ); expect(getByText("Error: Can't connect to Jenkins")).toBeInTheDocument(); @@ -82,14 +77,12 @@ describe('', () => { }), }; - const apis = ApiRegistry.from([[jenkinsApiRef, jenkinsApiWithError]]); - const { getByText } = await renderInTestApp( - + - , + , ); expect(getByText("Error: Can't find Jenkins project")).toBeInTheDocument(); diff --git a/plugins/jenkins/src/components/Router.tsx b/plugins/jenkins/src/components/Router.tsx index 35b769e9d9..0d38fe009a 100644 --- a/plugins/jenkins/src/components/Router.tsx +++ b/plugins/jenkins/src/components/Router.tsx @@ -19,7 +19,7 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import React from 'react'; import { Route, Routes } from 'react-router'; import { JENKINS_ANNOTATION, LEGACY_JENKINS_ANNOTATION } from '../constants'; -import { buildRouteRef, rootRouteRef } from '../plugin'; +import { buildRouteRef } from '../plugin'; import { CITable } from './BuildsPage/lib/CITable'; import { DetailedViewPage } from './BuildWithStepsPage/'; @@ -41,7 +41,7 @@ export const Router = (_props: Props) => { return ( - } /> + } /> } /> ); diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index d445a8cfb1..c32c9b50d1 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -20,20 +20,20 @@ import { createPlugin, createRoutableExtension, createRouteRef, + createSubRouteRef, discoveryApiRef, identityApiRef, } from '@backstage/core-plugin-api'; import { JenkinsClient, jenkinsApiRef } from './api'; export const rootRouteRef = createRouteRef({ - path: '', - title: 'Jenkins', + id: 'jenkins', }); -export const buildRouteRef = createRouteRef({ - path: 'build/:jobFullName/:buildNumber', - params: ['jobFullName', 'buildNumber'], - title: 'Jenkins build', +export const buildRouteRef = createSubRouteRef({ + id: 'jenkins/builds', + path: '/builds/:jobFullName/:buildNumber', + parent: rootRouteRef, }); export const jenkinsPlugin = createPlugin({ diff --git a/plugins/kafka-backend/CHANGELOG.md b/plugins/kafka-backend/CHANGELOG.md index 88111cd1de..9a417b04b0 100644 --- a/plugins/kafka-backend/CHANGELOG.md +++ b/plugins/kafka-backend/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/plugin-kafka-backend +## 0.2.12 + +### Patch Changes + +- 4f81bfd356: Add ACL requirements for kafka-backend plugin +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/backend-common@0.9.11 + +## 0.2.11 + +### Patch Changes + +- 9145449220: Update Kafka configuration types +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + ## 0.2.10 ### Patch Changes diff --git a/plugins/kafka-backend/README.md b/plugins/kafka-backend/README.md index d18a232fa4..637a5773e0 100644 --- a/plugins/kafka-backend/README.md +++ b/plugins/kafka-backend/README.md @@ -49,3 +49,7 @@ kafka: username: my-username password: my-password ``` + +### ACLs + +If you are using ACLs on Kafka, you will need to have the `DESCRIBE` ACL on both consumer groups and topics. diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index e271decc8b..7fb5578628 100644 --- a/plugins/kafka-backend/package.json +++ b/plugins/kafka-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kafka-backend", "description": "A Backstage backend plugin that integrates towards Kafka", - "version": "0.2.10", + "version": "0.2.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,10 +32,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/errors": "^0.1.3", + "@backstage/errors": "^0.1.5", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", @@ -44,7 +44,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/jest-when": "^2.7.2", "@types/lodash": "^4.14.151", "jest-when": "^3.1.0", diff --git a/plugins/kafka/CHANGELOG.md b/plugins/kafka/CHANGELOG.md index 2fe42565da..33ec4bd95a 100644 --- a/plugins/kafka/CHANGELOG.md +++ b/plugins/kafka/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-kafka +## 0.2.21 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.20 ### Patch Changes diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index 888c79db59..41d677aece 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kafka", "description": "A Backstage plugin that integrates towards Kafka", - "version": "0.2.20", + "version": "0.2.21", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,11 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.3", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -35,10 +35,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/kafka/src/Router.tsx b/plugins/kafka/src/Router.tsx index 977c71712e..c63019253b 100644 --- a/plugins/kafka/src/Router.tsx +++ b/plugins/kafka/src/Router.tsx @@ -18,7 +18,6 @@ import { Entity } from '@backstage/catalog-model'; import React from 'react'; import { Route, Routes } from 'react-router'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { rootCatalogKafkaRouteRef } from './plugin'; import { KAFKA_CONSUMER_GROUP_ANNOTATION } from './constants'; import { KafkaTopicsForConsumer } from './components/ConsumerGroupOffsets/ConsumerGroupOffsets'; import { MissingAnnotationEmptyState } from '@backstage/core-components'; @@ -44,10 +43,7 @@ export const Router = (_props: Props) => { return ( - } - /> + } /> ); }; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx index a90d3d608e..0be9cabacd 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx @@ -26,8 +26,8 @@ import { import { useConsumerGroupsOffsetsForEntity } from './useConsumerGroupsOffsetsForEntity'; import * as data from './__fixtures__/consumer-group-offsets.json'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; const consumerGroupOffsets = data as ConsumerGroupOffsetsResponse; @@ -59,14 +59,14 @@ describe('useConsumerGroupOffsets', () => { const wrapper = ({ children }: PropsWithChildren<{}>) => { return ( - {children} - + ); }; diff --git a/plugins/kafka/src/plugin.ts b/plugins/kafka/src/plugin.ts index 539b0a0fda..0c345cdabc 100644 --- a/plugins/kafka/src/plugin.ts +++ b/plugins/kafka/src/plugin.ts @@ -25,8 +25,7 @@ import { } from '@backstage/core-plugin-api'; export const rootCatalogKafkaRouteRef = createRouteRef({ - path: '*', - title: 'Kafka', + id: 'kafka', }); export const kafkaPlugin = createPlugin({ diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index d506571a3f..15a69b3967 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/plugin-kubernetes-backend +## 0.3.20 + +### Patch Changes + +- 65ddccb5e8: Added apiVersionOverrides config to allow for specifying api versions to use for kubernetes objects +- f6087fc8f8: Query CronJobs from Kubernetes with apiGroup BatchV1beta1 +- Updated dependencies + - @backstage/backend-common@0.9.12 + - @backstage/plugin-kubernetes-common@0.1.7 + +## 0.3.19 + +### Patch Changes + +- 37dc844728: Include CronJobs and Jobs as default objects returned by the kubernetes backend and add/update relevant types. +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/plugin-kubernetes-common@0.1.6 + - @backstage/backend-common@0.9.11 + ## 0.3.18 ### Patch Changes diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index f34815f328..7d3adac1c4 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -10,6 +10,7 @@ import type { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { Logger as Logger_2 } from 'winston'; import type { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; // Warning: (ae-missing-release-tag) "AWSClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -34,6 +35,7 @@ export interface ClusterDetails { name: string; // (undocumented) serviceAccountToken?: string | undefined; + skipMetricsLookup?: boolean; // (undocumented) skipTLSVerify?: boolean; // (undocumented) @@ -164,6 +166,11 @@ export interface KubernetesFetcher { fetchObjectsForService( params: ObjectFetchParams, ): Promise; + // (undocumented) + fetchPodMetricsByNamespace( + clusterDetails: ClusterDetails, + namespace: string, + ): Promise; } // Warning: (ae-missing-release-tag) "KubernetesObjectsProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -202,6 +209,8 @@ export type KubernetesObjectTypes = | 'deployments' | 'replicasets' | 'horizontalpodautoscalers' + | 'jobs' + | 'cronjobs' | 'ingresses' | 'customresources'; diff --git a/plugins/kubernetes-backend/examples/dice-roller/README.md b/plugins/kubernetes-backend/examples/dice-roller/README.md index 5f541bb61d..d4725e38c0 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/README.md +++ b/plugins/kubernetes-backend/examples/dice-roller/README.md @@ -27,14 +27,15 @@ Add or update `app-config.local.yaml` with the following: ```yaml kubernetes: - serviceLocatorMethod: 'multiTenant' + serviceLocatorMethod: + type: 'multiTenant' clusterLocatorMethods: - - 'config' - clusters: - - url: - name: minikube - serviceAccountToken: - authProvider: 'serviceAccount' + - type: 'config' + clusters: + - url: + name: minikube + serviceAccountToken: + authProvider: 'serviceAccount' ``` ### Getting the service account token diff --git a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml index 68ef489170..fb71037129 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml +++ b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml @@ -200,6 +200,35 @@ spec: maxReplicas: 15 targetCPUUtilizationPercentage: 50 +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: dice-roller-cronjob + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + schedule: '*/1 * * * *' + jobTemplate: + metadata: + labels: + 'backstage.io/kubernetes-id': dice-roller + spec: + template: + metadata: + labels: + 'backstage.io/kubernetes-id': dice-roller + spec: + containers: + - name: busybox + image: busybox + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -c + - date; echo Rolling a die! + restartPolicy: OnFailure + --- apiVersion: networking.k8s.io/v1beta1 kind: Ingress diff --git a/plugins/kubernetes-backend/examples/dice-roller/metrics-server.yaml b/plugins/kubernetes-backend/examples/dice-roller/metrics-server.yaml new file mode 100644 index 0000000000..673064aaf2 --- /dev/null +++ b/plugins/kubernetes-backend/examples/dice-roller/metrics-server.yaml @@ -0,0 +1,137 @@ +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: system:aggregated-metrics-reader + labels: + rbac.authorization.k8s.io/aggregate-to-view: 'true' + rbac.authorization.k8s.io/aggregate-to-edit: 'true' + rbac.authorization.k8s.io/aggregate-to-admin: 'true' +rules: + - apiGroups: ['metrics.k8s.io'] + resources: ['pods'] + verbs: ['get', 'list', 'watch'] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: metrics-server:system:auth-delegator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: metrics-server + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: RoleBinding +metadata: + name: metrics-server-auth-reader + namespace: kube-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: extension-apiserver-authentication-reader +subjects: + - kind: ServiceAccount + name: metrics-server + namespace: kube-system +--- +apiVersion: apiregistration.k8s.io/v1beta1 +kind: APIService +metadata: + name: v1beta1.metrics.k8s.io +spec: + service: + name: metrics-server + namespace: kube-system + group: metrics.k8s.io + version: v1beta1 + insecureSkipTLSVerify: true + groupPriorityMinimum: 100 + versionPriority: 100 +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: metrics-server + namespace: kube-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: metrics-server + namespace: kube-system + labels: + k8s-app: metrics-server +spec: + selector: + matchLabels: + k8s-app: metrics-server + template: + metadata: + name: metrics-server + labels: + k8s-app: metrics-server + spec: + serviceAccountName: metrics-server + volumes: + # mount in tmp so we can safely use from-scratch images and/or read-only containers + - name: tmp-dir + emptyDir: {} + containers: + - name: metrics-server + image: k8s.gcr.io/metrics-server-amd64:v0.3.1 + args: + - --kubelet-insecure-tls + - --kubelet-preferred-address-types=InternalIP + imagePullPolicy: Always + volumeMounts: + - name: tmp-dir + mountPath: /tmp + +--- +apiVersion: v1 +kind: Service +metadata: + name: metrics-server + namespace: kube-system + labels: + kubernetes.io/name: 'Metrics-server' +spec: + selector: + k8s-app: metrics-server + ports: + - port: 443 + protocol: TCP + targetPort: 443 +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: system:metrics-server +rules: + - apiGroups: + - '' + resources: + - pods + - nodes + - nodes/stats + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: system:metrics-server +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:metrics-server +subjects: + - kind: ServiceAccount + name: metrics-server + namespace: kube-system diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index be2903dff5..c2f4eae4ba 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kubernetes-backend", "description": "A Backstage backend plugin that integrates towards Kubernetes", - "version": "0.3.18", + "version": "0.3.20", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,13 +32,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/errors": "^0.1.3", - "@backstage/plugin-kubernetes-common": "^0.1.4", + "@backstage/errors": "^0.1.5", + "@backstage/plugin-kubernetes-common": "^0.1.7", "@google-cloud/container": "^2.2.0", - "@kubernetes/client-node": "^0.15.0", + "@kubernetes/client-node": "^0.16.0", "@types/express": "^4.17.6", "aws-sdk": "^2.840.0", "aws4": "^1.11.0", @@ -55,7 +55,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/aws4": "^1.5.1", "supertest": "^6.1.3", "aws-sdk-mock": "^5.2.1", diff --git a/plugins/kubernetes-backend/schema.d.ts b/plugins/kubernetes-backend/schema.d.ts index 8b96d5ceca..7f5183e337 100644 --- a/plugins/kubernetes-backend/schema.d.ts +++ b/plugins/kubernetes-backend/schema.d.ts @@ -23,6 +23,8 @@ export interface Config { | 'deployments' | 'replicasets' | 'horizontalpodautoscalers' + | 'jobs' + | 'cronjobs' | 'ingresses' >; serviceLocatorMethod: { @@ -61,5 +63,23 @@ export interface Config { apiVersion: string; plural: string; }>; + + /** + * (Optional) API Version Overrides + * If set, the specified api version will be used to make requests for the corresponding object. + * If running a legacy Kubernetes version, you may use this to override the default api versions + * that are not supported in your cluster. + */ + apiVersionOverrides?: { + pods?: string; + services?: string; + configmaps?: string; + deployments?: string; + replicasets?: string; + horizontalpodautoscalers?: string; + cronjobs?: string; + jobs?: string; + ingresses?: string; + } & { [pluralKind: string]: string }; }; } diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index 62346ac6c3..f5d6b56893 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -52,6 +52,7 @@ describe('ConfigClusterLocator', () => { serviceAccountToken: undefined, url: 'http://localhost:8080', authProvider: 'serviceAccount', + skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, }, @@ -67,6 +68,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8080', authProvider: 'serviceAccount', skipTLSVerify: false, + skipMetricsLookup: true, dashboardUrl: 'https://k8s.foo.com', }, { @@ -74,6 +76,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8081', authProvider: 'google', skipTLSVerify: true, + skipMetricsLookup: false, }, ], }); @@ -90,6 +93,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8080', authProvider: 'serviceAccount', skipTLSVerify: false, + skipMetricsLookup: true, caData: undefined, }, { @@ -98,6 +102,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8081', authProvider: 'google', skipTLSVerify: true, + skipMetricsLookup: false, caData: undefined, }, ]); @@ -144,6 +149,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8080', authProvider: 'aws', skipTLSVerify: false, + skipMetricsLookup: false, caData: undefined, }, { @@ -154,6 +160,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8081', authProvider: 'aws', skipTLSVerify: true, + skipMetricsLookup: false, caData: undefined, }, { @@ -164,6 +171,7 @@ describe('ConfigClusterLocator', () => { serviceAccountToken: undefined, authProvider: 'aws', skipTLSVerify: true, + skipMetricsLookup: false, caData: undefined, }, ]); diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index bb9dbfe13f..36b5f58f2c 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -35,6 +35,7 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { url: c.getString('url'), serviceAccountToken: c.getOptionalString('serviceAccountToken'), skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false, + skipMetricsLookup: c.getOptionalBoolean('skipMetricsLookup') ?? false, caData: c.getOptionalString('caData'), authProvider: authProvider, }; diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts index 545f839907..bd342f566b 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -93,6 +93,7 @@ describe('GkeClusterLocator', () => { type: 'gke', projectId: 'some-project', region: 'some-region', + skipMetricsLookup: true, }); const sut = GkeClusterLocator.fromConfigWithClient(config, { @@ -107,6 +108,7 @@ describe('GkeClusterLocator', () => { name: 'some-cluster', url: 'https://1.2.3.4', skipTLSVerify: false, + skipMetricsLookup: true, }, ]); expect(mockedListClusters).toBeCalledTimes(1); @@ -143,6 +145,7 @@ describe('GkeClusterLocator', () => { name: 'some-cluster', url: 'https://1.2.3.4', skipTLSVerify: false, + skipMetricsLookup: false, }, ]); expect(mockedListClusters).toBeCalledTimes(1); @@ -184,12 +187,14 @@ describe('GkeClusterLocator', () => { name: 'some-cluster', url: 'https://1.2.3.4', skipTLSVerify: false, + skipMetricsLookup: false, }, { authProvider: 'google', name: 'some-other-cluster', url: 'https://6.7.8.9', skipTLSVerify: false, + skipMetricsLookup: false, }, ]); expect(mockedListClusters).toBeCalledTimes(1); diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 3aa4a3e941..d7f5050399 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -23,6 +23,7 @@ type GkeClusterLocatorOptions = { projectId: string; region?: string; skipTLSVerify?: boolean; + skipMetricsLookup?: boolean; }; export class GkeClusterLocator implements KubernetesClustersSupplier { @@ -39,6 +40,8 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { projectId: config.getString('projectId'), region: config.getOptionalString('region') ?? '-', skipTLSVerify: config.getOptionalBoolean('skipTLSVerify') ?? false, + skipMetricsLookup: + config.getOptionalBoolean('skipMetricsLookup') ?? false, }; return new GkeClusterLocator(options, client); } @@ -52,7 +55,8 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { // TODO pass caData into the object async getClusters(): Promise { - const { projectId, region, skipTLSVerify } = this.options; + const { projectId, region, skipTLSVerify, skipMetricsLookup } = + this.options; const request = { parent: `projects/${projectId}/locations/${region}`, }; @@ -65,6 +69,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { url: `https://${r.endpoint ?? ''}`, authProvider: 'google', skipTLSVerify, + skipMetricsLookup, })); } catch (e) { throw new ForwardedError( diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index dde4dc1d93..2bc2c719a0 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -53,6 +53,7 @@ describe('getCombinedClusterDetails', () => { serviceAccountToken: 'token', url: 'http://localhost:8080', authProvider: 'serviceAccount', + skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, }, @@ -61,6 +62,7 @@ describe('getCombinedClusterDetails', () => { serviceAccountToken: undefined, url: 'http://localhost:8081', authProvider: 'google', + skipMetricsLookup: false, skipTLSVerify: false, caData: undefined, }, diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index de0dcdf373..37dad8c3e4 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -29,6 +29,7 @@ import { } from '../types/types'; import { KubernetesBuilder } from './KubernetesBuilder'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; +import { PodStatus } from '@kubernetes/client-node'; describe('KubernetesBuilder', () => { let app: express.Express; @@ -194,6 +195,7 @@ describe('KubernetesBuilder', () => { name: someCluster.name, }, errors: [], + podMetrics: [], resources: [ { type: 'pods', @@ -211,6 +213,12 @@ describe('KubernetesBuilder', () => { }; const fetcher: KubernetesFetcher = { + fetchPodMetricsByNamespace( + _clusterDetails: ClusterDetails, + _namespace: string, + ): Promise { + return Promise.resolve([]); + }, fetchObjectsForService( _params: ObjectFetchParams, ): Promise { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 11cca4917f..3333f7a0fc 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -243,6 +243,10 @@ export class KubernetesBuilder { 'kubernetes.objectTypes', ) as KubernetesObjectTypes[]; + const apiVersionOverrides = this.env.config.getOptionalConfig( + 'kubernetes.apiVersionOverrides', + ); + let objectTypesToFetch; if (objectTypesToFetchStrings) { @@ -250,6 +254,17 @@ export class KubernetesBuilder { objectTypesToFetchStrings.includes(obj.objectType), ); } + + if (apiVersionOverrides) { + objectTypesToFetch = objectTypesToFetch ?? DEFAULT_OBJECTS; + + for (const obj of objectTypesToFetch) { + if (apiVersionOverrides.has(obj.objectType)) { + obj.apiVersion = apiVersionOverrides.getString(obj.objectType); + } + } + } + return objectTypesToFetch; } } diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts index 96bd1fe7c1..e813a63f0d 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts @@ -47,14 +47,14 @@ describe('KubernetesClientProvider', () => { expect(mockGetKubeConfig.mock.calls.length).toBe(1); }); - it('can get apps client by cluster details', async () => { + it('can get custom objects client by cluster details', async () => { const sut = new KubernetesClientProvider(); const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({})); sut.getKubeConfig = mockGetKubeConfig; - const result = sut.getAppsClientByClusterDetails({ + const result = sut.getCustomObjectsClient({ name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts index 4b2848508e..031e40547c 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts @@ -15,11 +15,9 @@ */ import { - AppsV1Api, - AutoscalingV1Api, CoreV1Api, KubeConfig, - NetworkingV1beta1Api, + Metrics, CustomObjectsApi, } from '@kubernetes/client-node'; import { ClusterDetails } from '../types/types'; @@ -62,22 +60,10 @@ export class KubernetesClientProvider { return kc.makeApiClient(CoreV1Api); } - getAppsClientByClusterDetails(clusterDetails: ClusterDetails) { + getMetricsClient(clusterDetails: ClusterDetails) { const kc = this.getKubeConfig(clusterDetails); - return kc.makeApiClient(AppsV1Api); - } - - getAutoscalingClientByClusterDetails(clusterDetails: ClusterDetails) { - const kc = this.getKubeConfig(clusterDetails); - - return kc.makeApiClient(AutoscalingV1Api); - } - - getNetworkingBeta1Client(clusterDetails: ClusterDetails) { - const kc = this.getKubeConfig(clusterDetails); - - return kc.makeApiClient(NetworkingV1beta1Api); + return new Metrics(kc); } getCustomObjectsClient(clusterDetails: ClusterDetails) { diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index 7973448467..da8f703ba0 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -15,13 +15,30 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { ObjectFetchParams } from '../types/types'; +import { ClusterDetails, ObjectFetchParams } from '../types/types'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; const fetchObjectsForService = jest.fn(); +const fetchPodMetricsByNamespace = jest.fn(); const getClustersByServiceId = jest.fn(); +const POD_METRICS_FIXTURE = { + containers: [], + cpu: { + currentUsage: 100, + limitTotal: 102, + requestTotal: 101, + }, + memory: { + currentUsage: '1000', + limitTotal: '1002', + requestTotal: '1001', + }, + pod: {}, +}; + const mockFetch = (mock: jest.Mock) => { mock.mockImplementation((params: ObjectFetchParams) => Promise.resolve( @@ -33,6 +50,34 @@ const mockFetch = (mock: jest.Mock) => { ); }; +const mockMetrics = (mock: jest.Mock) => { + mock.mockImplementation((clusterDetails: ClusterDetails, namespace: string) => + Promise.resolve(generatePodStatus(clusterDetails.name, namespace)), + ); +}; + +function generatePodStatus( + _clusterName: string, + _namespace: string, +): PodStatus[] { + return [ + { + Pod: {}, + CPU: { + CurrentUsage: 100, + RequestTotal: 101, + LimitTotal: 102, + }, + Memory: { + CurrentUsage: BigInt('1000'), + RequestTotal: BigInt('1001'), + LimitTotal: BigInt('1002'), + }, + Containers: [], + }, + ] as any; +} + function generateMockResourcesAndErrors( serviceId: string, clusterName: string, @@ -84,6 +129,7 @@ function generateMockResourcesAndErrors( { metadata: { name: `my-pods-${serviceId}-${clusterName}`, + namespace: `ns-${serviceId}-${clusterName}`, }, }, ], @@ -94,6 +140,7 @@ function generateMockResourcesAndErrors( { metadata: { name: `my-configmaps-${serviceId}-${clusterName}`, + namespace: `ns-${serviceId}-${clusterName}`, }, }, ], @@ -104,6 +151,7 @@ function generateMockResourcesAndErrors( { metadata: { name: `my-services-${serviceId}-${clusterName}`, + namespace: `ns-${serviceId}-${clusterName}`, }, }, ], @@ -128,11 +176,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -161,6 +211,11 @@ describe('handleGetKubernetesObjectsForService', () => { expect(getClustersByServiceId.mock.calls.length).toBe(1); expect(fetchObjectsForService.mock.calls.length).toBe(1); + expect(fetchPodMetricsByNamespace.mock.calls.length).toBe(1); + expect(fetchPodMetricsByNamespace.mock.calls[0][1]).toBe( + 'ns-test-component-test-cluster', + ); + expect(result).toStrictEqual({ items: [ { @@ -168,12 +223,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -184,6 +241,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -194,6 +252,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -205,6 +264,124 @@ describe('handleGetKubernetesObjectsForService', () => { }); }); + it('dont call top for the same namespace twice', async () => { + getClustersByServiceId.mockImplementation(() => + Promise.resolve([ + { + name: 'test-cluster', + authProvider: 'serviceAccount', + }, + ]), + ); + + fetchObjectsForService.mockImplementation((_: ObjectFetchParams) => + Promise.resolve({ + errors: [], + responses: [ + { + type: 'pods', + resources: [ + { + metadata: { + name: `pod1`, + namespace: `ns-a`, + }, + }, + { + metadata: { + name: `pod2`, + namespace: `ns-a`, + }, + }, + { + metadata: { + name: `pod3`, + namespace: `ns-b`, + }, + }, + ], + }, + ], + }), + ); + + mockMetrics(fetchPodMetricsByNamespace); + + const sut = new KubernetesFanOutHandler({ + logger: getVoidLogger(), + fetcher: { + fetchObjectsForService, + fetchPodMetricsByNamespace, + }, + serviceLocator: { + getClustersByServiceId, + }, + customResources: [], + }); + + const result = await sut.getKubernetesObjectsByEntity({ + 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(1); + expect(fetchPodMetricsByNamespace.mock.calls.length).toBe(2); + expect(fetchPodMetricsByNamespace.mock.calls[0][1]).toBe('ns-a'); + expect(fetchPodMetricsByNamespace.mock.calls[1][1]).toBe('ns-b'); + + expect(result).toStrictEqual({ + items: [ + { + cluster: { + name: 'test-cluster', + }, + errors: [], + podMetrics: [POD_METRICS_FIXTURE, POD_METRICS_FIXTURE], + resources: [ + { + resources: [ + { + metadata: { + name: 'pod1', + namespace: 'ns-a', + }, + }, + { + metadata: { + name: 'pod2', + namespace: 'ns-a', + }, + }, + { + metadata: { + name: 'pod3', + namespace: 'ns-b', + }, + }, + ], + type: 'pods', + }, + ], + }, + ], + }); + }); + it('retrieve objects for two clusters', async () => { getClustersByServiceId.mockImplementation(() => Promise.resolve([ @@ -221,11 +398,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -265,12 +444,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -281,6 +462,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -291,6 +473,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -303,12 +486,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'other-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -319,6 +504,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -329,6 +515,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -358,11 +545,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -401,12 +590,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -417,6 +608,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -427,6 +619,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -439,12 +632,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'other-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -455,6 +650,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -465,6 +661,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -498,11 +695,13 @@ describe('handleGetKubernetesObjectsForService', () => { ); mockFetch(fetchObjectsForService); + mockMetrics(fetchPodMetricsByNamespace); const sut = new KubernetesFanOutHandler({ logger: getVoidLogger(), fetcher: { fetchObjectsForService, + fetchPodMetricsByNamespace, }, serviceLocator: { getClustersByServiceId, @@ -541,12 +740,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'test-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -557,6 +758,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -567,6 +769,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-test-cluster', + namespace: 'ns-test-component-test-cluster', }, }, ], @@ -579,12 +782,14 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'other-cluster', }, errors: [], + podMetrics: [POD_METRICS_FIXTURE], resources: [ { resources: [ { metadata: { name: 'my-pods-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -595,6 +800,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-configmaps-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -605,6 +811,7 @@ describe('handleGetKubernetesObjectsForService', () => { { metadata: { name: 'my-services-test-component-other-cluster', + namespace: 'ns-test-component-other-cluster', }, }, ], @@ -617,6 +824,7 @@ describe('handleGetKubernetesObjectsForService', () => { name: 'error-cluster', }, errors: ['some random cluster error'], + podMetrics: [], resources: [ { type: 'pods', diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 1f856e90a9..a25c220b80 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -27,9 +27,19 @@ import { import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; import { + ClientContainerStatus, + ClientCurrentResourceUsage, + ClientPodStatus, ClusterObjects, + FetchResponse, ObjectsByEntityResponse, + PodFetchResponse, } from '@backstage/plugin-kubernetes-common'; +import { + ContainerStatus, + CurrentResourceUsage, + PodStatus, +} from '@kubernetes/client-node'; export const DEFAULT_OBJECTS: ObjectToFetch[] = [ { @@ -68,6 +78,18 @@ export const DEFAULT_OBJECTS: ObjectToFetch[] = [ plural: 'horizontalpodautoscalers', objectType: 'horizontalpodautoscalers', }, + { + group: 'batch', + apiVersion: 'v1', + plural: 'jobs', + objectType: 'jobs', + }, + { + group: 'batch', + apiVersion: 'v1', + plural: 'cronjobs', + objectType: 'cronjobs', + }, { group: 'networking.k8s.io', apiVersion: 'v1', @@ -81,6 +103,50 @@ export interface KubernetesFanOutHandlerOptions export interface KubernetesRequestBody extends ObjectsByEntityRequest {} +const isPodFetchResponse = (fr: FetchResponse): fr is PodFetchResponse => + fr.type === 'pods'; +const isString = (str: string | undefined): str is string => str !== undefined; + +const numberOrBigIntToNumberOrString = ( + value: number | BigInt, +): number | string => { + // @ts-ignore + return typeof value === 'bigint' ? value.toString() : value; +}; + +const toClientSafeResource = ( + current: CurrentResourceUsage, +): ClientCurrentResourceUsage => { + return { + currentUsage: numberOrBigIntToNumberOrString(current.CurrentUsage), + requestTotal: numberOrBigIntToNumberOrString(current.RequestTotal), + limitTotal: numberOrBigIntToNumberOrString(current.LimitTotal), + }; +}; + +const toClientSafeContainer = ( + container: ContainerStatus, +): ClientContainerStatus => { + return { + container: container.Container, + cpuUsage: toClientSafeResource(container.CPUUsage), + memoryUsage: toClientSafeResource(container.MemoryUsage), + }; +}; + +const toClientSafePodMetrics = ( + podMetrics: PodStatus[][], +): ClientPodStatus[] => { + return podMetrics.flat().map((pd: PodStatus): ClientPodStatus => { + return { + pod: pd.Pod, + memory: toClientSafeResource(pd.Memory), + cpu: toClientSafeResource(pd.CPU), + containers: pd.Containers.map(toClientSafeContainer), + }; + }); +}; + export class KubernetesFanOutHandler { private readonly logger: Logger; private readonly fetcher: KubernetesFetcher; @@ -150,10 +216,36 @@ export class KubernetesFanOutHandler { customResources: this.customResources, }) .then(result => { + if (clusterDetailsItem.skipMetricsLookup) { + return Promise.all([ + Promise.resolve(result), + Promise.resolve([]), + ]); + } + // TODO refactor, extract as method + const namespaces: Set = new Set( + result.responses + .filter(isPodFetchResponse) + .flatMap(r => r.resources) + .map(p => p.metadata?.namespace) + .filter(isString), + ); + + const podMetrics = Array.from(namespaces).map(ns => + this.fetcher.fetchPodMetricsByNamespace(clusterDetailsItem, ns), + ); + + return Promise.all([ + Promise.resolve(result), + Promise.all(podMetrics), + ]); + }) + .then(([result, metrics]) => { const objects: ClusterObjects = { cluster: { name: clusterDetailsItem.name, }, + podMetrics: toClientSafePodMetrics(metrics), resources: result.responses, errors: result.errors, }; diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index e86901ef92..e1f701b75c 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -106,6 +106,7 @@ describe('KubernetesFetcher', () => { 'v1', 'pods', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -116,6 +117,7 @@ describe('KubernetesFetcher', () => { 'v1', 'services', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -197,6 +199,7 @@ describe('KubernetesFetcher', () => { 'v1', 'pods', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -207,6 +210,7 @@ describe('KubernetesFetcher', () => { 'v1', 'services', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -316,6 +320,7 @@ describe('KubernetesFetcher', () => { 'v1', 'pods', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -326,6 +331,7 @@ describe('KubernetesFetcher', () => { 'v1', 'services', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', @@ -336,6 +342,7 @@ describe('KubernetesFetcher', () => { 'v2', 'things', '', + false, '', '', 'backstage.io/kubernetes-id=some-service', diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index 308b7a10be..a2c3b9a6a8 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - AppsV1Api, - AutoscalingV1Api, - CoreV1Api, - NetworkingV1beta1Api, -} from '@kubernetes/client-node'; +import { CoreV1Api, topPods } from '@kubernetes/client-node'; import lodash, { Dictionary } from 'lodash'; import { Logger } from 'winston'; import { @@ -36,12 +31,10 @@ import { KubernetesErrorTypes, } from '@backstage/plugin-kubernetes-common'; import { KubernetesClientProvider } from './KubernetesClientProvider'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; export interface Clients { core: CoreV1Api; - apps: AppsV1Api; - autoscaling: AutoscalingV1Api; - networkingBeta1: NetworkingV1beta1Api; } export interface KubernetesClientBasedFetcherOptions { @@ -110,10 +103,26 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { return Promise.all(fetchResults).then(fetchResultsToResponseWrapper); } + fetchPodMetricsByNamespace( + clusterDetails: ClusterDetails, + namespace: string, + ): Promise { + const metricsClient = + this.kubernetesClientProvider.getMetricsClient(clusterDetails); + const coreApi = + this.kubernetesClientProvider.getCoreClientByClusterDetails( + clusterDetails, + ); + + return topPods(coreApi, metricsClient, namespace); + } + private captureKubernetesErrorsRethrowOthers(e: any): KubernetesFetchError { if (e.response && e.response.statusCode) { - this.logger.info( - `statusCode=${e.response.statusCode} for resource ${e.response.request.uri.pathname}`, + this.logger.warn( + `statusCode=${e.response.statusCode} for resource ${ + e.response.request.uri.pathname + } body=[${JSON.stringify(e.response.body)}]`, ); return { errorType: statusCodeToErrorType(e.response.statusCode), @@ -143,6 +152,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { resource.apiVersion, resource.plural, '', + false, '', '', labelSelector, diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 90485fcaee..a0ec0832c1 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -21,6 +21,7 @@ import type { KubernetesRequestBody, ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-common'; +import { PodStatus } from '@kubernetes/client-node/dist/top'; export interface ObjectFetchParams { serviceId: string; @@ -40,6 +41,10 @@ export interface KubernetesFetcher { fetchObjectsForService( params: ObjectFetchParams, ): Promise; + fetchPodMetricsByNamespace( + clusterDetails: ClusterDetails, + namespace: string, + ): Promise; } export interface FetchResponseWrapper { @@ -67,6 +72,8 @@ export type KubernetesObjectTypes = | 'deployments' | 'replicasets' | 'horizontalpodautoscalers' + | 'jobs' + | 'cronjobs' | 'ingresses' | 'customresources'; @@ -91,6 +98,11 @@ export interface ClusterDetails { authProvider: string; serviceAccountToken?: string | undefined; skipTLSVerify?: boolean; + /** + * Whether to skip the lookup to the metrics server to retrieve pod resource usage. + * It is not guaranteed that the Kubernetes distro has the metrics server installed. + */ + skipMetricsLookup?: boolean; caData?: string | undefined; /** * Specifies the link to the Kubernetes dashboard managing this cluster. diff --git a/plugins/kubernetes-common/CHANGELOG.md b/plugins/kubernetes-common/CHANGELOG.md index 0f685bd4d4..db18c64482 100644 --- a/plugins/kubernetes-common/CHANGELOG.md +++ b/plugins/kubernetes-common/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-kubernetes-common +## 0.1.7 + +### Patch Changes + +- 59677fadb1: Improvements to API Reference documentation + +## 0.1.6 + +### Patch Changes + +- 37dc844728: Include CronJobs and Jobs as default objects returned by the kubernetes backend and add/update relevant types. + ## 0.1.5 ### Patch Changes diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 9f9669275f..f3500aa271 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -4,10 +4,12 @@ ```ts import { Entity } from '@backstage/catalog-model'; -import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; import { V1ConfigMap } from '@kubernetes/client-node'; +import { V1CronJob } from '@kubernetes/client-node'; import { V1Deployment } from '@kubernetes/client-node'; import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; +import { V1Ingress } from '@kubernetes/client-node'; +import { V1Job } from '@kubernetes/client-node'; import { V1Pod } from '@kubernetes/client-node'; import { V1ReplicaSet } from '@kubernetes/client-node'; import { V1Service } from '@kubernetes/client-node'; @@ -17,6 +19,44 @@ import { V1Service } from '@kubernetes/client-node'; // @public (undocumented) export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; +// Warning: (ae-missing-release-tag) "ClientContainerStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ClientContainerStatus { + // (undocumented) + container: string; + // (undocumented) + cpuUsage: ClientCurrentResourceUsage; + // (undocumented) + memoryUsage: ClientCurrentResourceUsage; +} + +// Warning: (ae-missing-release-tag) "ClientCurrentResourceUsage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ClientCurrentResourceUsage { + // (undocumented) + currentUsage: number | string; + // (undocumented) + limitTotal: number | string; + // (undocumented) + requestTotal: number | string; +} + +// Warning: (ae-missing-release-tag) "ClientPodStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ClientPodStatus { + // (undocumented) + containers: ClientContainerStatus[]; + // (undocumented) + cpu: ClientCurrentResourceUsage; + // (undocumented) + memory: ClientCurrentResourceUsage; + // (undocumented) + pod: V1Pod; +} + // Warning: (ae-missing-release-tag) "ClusterAttributes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -35,6 +75,8 @@ export interface ClusterObjects { // (undocumented) errors: KubernetesFetchError[]; // (undocumented) + podMetrics: ClientPodStatus[]; + // (undocumented) resources: FetchResponse[]; } @@ -48,6 +90,16 @@ export interface ConfigMapFetchResponse { type: 'configmaps'; } +// Warning: (ae-missing-release-tag) "CronJobsFetchResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface CronJobsFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'cronjobs'; +} + // Warning: (ae-missing-release-tag) "CustomResourceFetchResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -78,6 +130,8 @@ export type FetchResponse = | DeploymentFetchResponse | ReplicaSetsFetchResponse | HorizontalPodAutoscalersFetchResponse + | JobsFetchResponse + | CronJobsFetchResponse | IngressesFetchResponse | CustomResourceFetchResponse; @@ -96,11 +150,21 @@ export interface HorizontalPodAutoscalersFetchResponse { // @public (undocumented) export interface IngressesFetchResponse { // (undocumented) - resources: Array; + resources: Array; // (undocumented) type: 'ingresses'; } +// Warning: (ae-missing-release-tag) "JobsFetchResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface JobsFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'jobs'; +} + // Warning: (ae-missing-release-tag) "KubernetesErrorTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/kubernetes-common/package.json b/plugins/kubernetes-common/package.json index 28dfc94c49..b3de378021 100644 --- a/plugins/kubernetes-common/package.json +++ b/plugins/kubernetes-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kubernetes-common", "description": "Common functionalities for kubernetes, to be shared between kubernetes and kubernetes-backend plugin", - "version": "0.1.5", + "version": "0.1.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -35,11 +35,11 @@ "url": "https://github.com/backstage/backstage/issues" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@kubernetes/client-node": "^0.15.0" + "@backstage/catalog-model": "^0.9.7", + "@kubernetes/client-node": "^0.16.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0" + "@backstage/cli": "^0.10.0" }, "jest": { "roots": [ diff --git a/plugins/kubernetes-common/src/index.ts b/plugins/kubernetes-common/src/index.ts index af146ae9fa..6b97c0eb7d 100644 --- a/plugins/kubernetes-common/src/index.ts +++ b/plugins/kubernetes-common/src/index.ts @@ -15,7 +15,7 @@ */ /** - * Common functionalities for kubernetes, to be shared between kubernetes and kubernetes-backend plugin + * Common functionalities for Kubernetes, to be shared between the `kubernetes` and `kubernetes-backend` plugins * * @packageDocumentation */ diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index add661a5f8..0c5af00a97 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -15,10 +15,12 @@ */ import { - ExtensionsV1beta1Ingress, V1ConfigMap, + V1CronJob, V1Deployment, V1HorizontalPodAutoscaler, + V1Ingress, + V1Job, V1Pod, V1ReplicaSet, V1Service, @@ -67,6 +69,7 @@ export interface ClusterAttributes { export interface ClusterObjects { cluster: ClusterAttributes; resources: FetchResponse[]; + podMetrics: ClientPodStatus[]; errors: KubernetesFetchError[]; } @@ -83,6 +86,8 @@ export type FetchResponse = | DeploymentFetchResponse | ReplicaSetsFetchResponse | HorizontalPodAutoscalersFetchResponse + | JobsFetchResponse + | CronJobsFetchResponse | IngressesFetchResponse | CustomResourceFetchResponse; @@ -116,9 +121,19 @@ export interface HorizontalPodAutoscalersFetchResponse { resources: Array; } +export interface JobsFetchResponse { + type: 'jobs'; + resources: Array; +} + +export interface CronJobsFetchResponse { + type: 'cronjobs'; + resources: Array; +} + export interface IngressesFetchResponse { type: 'ingresses'; - resources: Array; + resources: Array; } export interface CustomResourceFetchResponse { @@ -137,3 +152,22 @@ export type KubernetesErrorTypes = | 'UNAUTHORIZED_ERROR' | 'SYSTEM_ERROR' | 'UNKNOWN_ERROR'; + +export interface ClientCurrentResourceUsage { + currentUsage: number | string; + requestTotal: number | string; + limitTotal: number | string; +} + +export interface ClientContainerStatus { + container: string; + cpuUsage: ClientCurrentResourceUsage; + memoryUsage: ClientCurrentResourceUsage; +} + +export interface ClientPodStatus { + pod: V1Pod; + cpu: ClientCurrentResourceUsage; + memory: ClientCurrentResourceUsage; + containers: ClientContainerStatus[]; +} diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index a757041b32..819709599f 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,37 @@ # @backstage/plugin-kubernetes +## 0.4.22 + +### Patch Changes + +- 86ed770308: Added accordions to display information on Jobs and CronJobs in the kubernetes plugin. Updated the PodsTable with fewer default columns and the ability to pass in additional ones depending on the use case. +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + - @backstage/plugin-kubernetes-common@0.1.7 + +## 0.4.21 + +### Patch Changes + +- 3739d3f773: Implement support for formatting OpenShift dashboard url links +- Updated dependencies + - @backstage/plugin-kubernetes-common@0.1.6 + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.4.20 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.4.19 ### Patch Changes diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index a2c1114c2d..068f26b7f5 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -29,7 +29,9 @@ import { } from '@backstage/plugin-kubernetes-common'; import fixture1 from '../src/__fixtures__/1-deployments.json'; import fixture2 from '../src/__fixtures__/2-deployments.json'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import fixture3 from '../src/__fixtures__/1-cronjobs.json'; +import fixture4 from '../src/__fixtures__/2-cronjobs.json'; +import { TestApiProvider } from '@backstage/test-utils'; const mockEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -64,6 +66,7 @@ class MockKubernetesClient implements KubernetesApi { { cluster: { name: 'mock-cluster' }, resources: this.resources, + podMetrics: [], errors: [], }, ], @@ -80,32 +83,52 @@ createDevApp() path: '/fixture-1', title: 'Fixture 1', element: ( - - + ), }) .addPage({ path: '/fixture-2', title: 'Fixture 2', element: ( - - + + ), + }) + .addPage({ + path: '/fixture-3', + title: 'Fixture 3', + element: ( + + + + + + ), + }) + .addPage({ + path: '/fixture-4', + title: 'Fixture 4', + element: ( + + + + + ), }) .registerPlugin(kubernetesPlugin) diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 9dbca8aff8..5e7a5bdf8d 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-kubernetes", "description": "A Backstage plugin that integrates towards Kubernetes", - "version": "0.4.19", + "version": "0.4.22", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,17 +31,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.3", - "@backstage/plugin-kubernetes-common": "^0.1.5", - "@backstage/theme": "^0.2.13", - "@kubernetes/client-node": "^0.15.0", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/plugin-kubernetes-common": "^0.1.7", + "@kubernetes/client-node": "^0.16.0", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", + "cronstrue": "^1.122.0", "js-yaml": "^4.0.0", "lodash": "^4.17.21", "luxon": "^2.0.2", @@ -51,10 +52,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/kubernetes/src/Router.tsx b/plugins/kubernetes/src/Router.tsx index f98d3ca45e..873bf128e4 100644 --- a/plugins/kubernetes/src/Router.tsx +++ b/plugins/kubernetes/src/Router.tsx @@ -18,7 +18,6 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Route, Routes } from 'react-router-dom'; -import { rootCatalogKubernetesRouteRef } from './plugin'; import { KubernetesContent } from './components/KubernetesContent'; import { Button } from '@material-ui/core'; import { MissingAnnotationEmptyState } from '@backstage/core-components'; @@ -53,10 +52,7 @@ export const Router = (_props: Props) => { ) { return ( - } - /> + } /> ); } diff --git a/plugins/kubernetes/src/__fixtures__/1-cronjobs.json b/plugins/kubernetes/src/__fixtures__/1-cronjobs.json new file mode 100644 index 0000000000..1edc43710d --- /dev/null +++ b/plugins/kubernetes/src/__fixtures__/1-cronjobs.json @@ -0,0 +1,446 @@ +{ + "cronJobs": [ + { + "metadata": { + "name": "dice-roller-cronjob", + "namespace": "default", + "uid": "9ea073bc-7a4b-4b99-8321-0305bce85568", + "resourceVersion": "1362732552", + "creationTimestamp": "2021-07-16T22:39:58Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "annotations": {} + }, + "spec": { + "schedule": "30 5 * * *", + "startingDeadlineSeconds": 1800, + "concurrencyPolicy": "Forbid", + "suspend": false, + "jobTemplate": { + "metadata": { "creationTimestamp": null }, + "spec": { + "backoffLimit": 2, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "annotations": {} + }, + "spec": { + "containers": [ + { + "name": "busybox", + "image": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "IfNotPresent", + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"] + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + } + }, + "successfulJobsHistoryLimit": 2, + "failedJobsHistoryLimit": 2 + }, + "status": { + "active": [ + { + "kind": "Job", + "namespace": "default", + "name": "dice-roller-cronjob-1637028600", + "uid": "32be1b89-5b53-45b2-aa84-277e75214f61", + "apiVersion": "batch/v1", + "resourceVersion": "1361174163" + } + ], + "lastScheduleTime": "2021-11-16T02:10:00Z" + } + } + ], + "jobs": [ + { + "metadata": { + "name": "dice-roller-cronjob-1637025000", + "namespace": "default", + "uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "resourceVersion": "1361029181", + "creationTimestamp": "2021-11-16T01:10:24Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "CronJob", + "name": "dice-roller-cronjob", + "uid": "9ea073bc-7a4b-4b99-8321-0305bce85568", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "parallelism": 1, + "completions": 1, + "backoffLimit": 2, + "selector": { + "matchLabels": { + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "job-name": "dice-roller-cronjob-1637025000" + }, + "annotations": {} + }, + "spec": { + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + }, + "status": { + "conditions": [ + { + "type": "Complete", + "status": "True", + "lastProbeTime": "2021-11-16T01:11:31Z", + "lastTransitionTime": "2021-11-16T01:11:31Z" + } + ], + "startTime": "2021-11-16T01:10:24Z", + "completionTime": "2021-11-16T01:11:31Z", + "succeeded": 1 + } + }, + { + "metadata": { + "name": "dice-roller-cronjob-1637028600", + "namespace": "default", + "uid": "32be1b89-5b53-45b2-aa84-277e75214f61", + "resourceVersion": "1361174166", + "creationTimestamp": "2021-11-16T02:10:22Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "CronJob", + "name": "dice-roller-cronjob", + "uid": "9ea073bc-7a4b-4b99-8321-0305bce85568", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "parallelism": 1, + "completions": 1, + "backoffLimit": 2, + "selector": { + "matchLabels": { + "controller-uid": "32be1b89-5b53-45b2-aa84-277e75214f61" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "32be1b89-5b53-45b2-aa84-277e75214f61", + "job-name": "dice-roller-cronjob-1637028600" + }, + "annotations": {} + }, + "spec": { + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + }, + "status": { "startTime": "2021-11-16T02:10:22Z", "active": 1 } + } + ], + "pods": [ + { + "metadata": { + "name": "dice-roller-cronjob-1637025000-gstc4", + "generateName": "dice-roller-cronjob-1637025000-", + "namespace": "default", + "uid": "3b0f2b65-5ae2-441a-beda-bdc92bcafaf0", + "resourceVersion": "1361029179", + "creationTimestamp": "2021-11-16T01:10:24Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "job-name": "dice-roller-cronjob-1637025000" + }, + "annotations": {}, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "Job", + "name": "dice-roller-cronjob-1637025000", + "uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "volumes": [], + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "default", + "serviceAccount": "default", + "nodeName": "minikube", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + } + ], + "priority": 0, + "enableServiceLinks": true + }, + "status": { + "phase": "Succeeded", + "conditions": [ + { + "type": "Initialized", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T01:10:27Z", + "reason": "PodCompleted" + }, + { + "type": "Ready", + "status": "False", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T01:11:31Z", + "reason": "PodCompleted" + }, + { + "type": "ContainersReady", + "status": "False", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T01:11:31Z", + "reason": "PodCompleted" + }, + { + "type": "PodScheduled", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T01:10:24Z" + } + ], + "hostIP": "192.168.49.2", + "podIP": "172.17.0.25", + "podIPs": [{ "ip": "172.17.0.25" }], + "startTime": "2021-11-16T01:10:24Z", + "containerStatuses": [ + { + "name": "busybox", + "state": { + "terminated": { + "exitCode": 0, + "reason": "Completed", + "startedAt": "2021-11-16T01:10:31Z", + "finishedAt": "2021-11-16T01:11:30Z", + "containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615" + } + }, + "lastState": {}, + "ready": false, + "restartCount": 0, + "image": "busybox:latest/node", + "imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353", + "containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615", + "started": false + } + ], + "qosClass": "Burstable" + } + }, + { + "metadata": { + "name": "dice-roller-cronjob-1637028600-p4mlc", + "generateName": "dice-roller-cronjob-1637028600-", + "namespace": "default", + "uid": "acddd5d2-ac7f-473b-a9d8-17a89f99ac39", + "resourceVersion": "1361174579", + "creationTimestamp": "2021-11-16T02:10:22Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "32be1b89-5b53-45b2-aa84-277e75214f61", + "job-name": "dice-roller-cronjob-1637028600" + }, + "annotations": {}, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "Job", + "name": "dice-roller-cronjob-1637028600", + "uid": "32be1b89-5b53-45b2-aa84-277e75214f61", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "volumes": [], + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "default", + "serviceAccount": "default", + "nodeName": "minikube", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + } + ], + "priority": 0, + "enableServiceLinks": true + }, + "status": { + "phase": "Running", + "conditions": [ + { + "type": "Initialized", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T02:10:25Z" + }, + { + "type": "Ready", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T02:10:35Z" + }, + { + "type": "ContainersReady", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T02:10:35Z" + }, + { + "type": "PodScheduled", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2021-11-16T02:10:22Z" + } + ], + "hostIP": "192.168.49.2", + "podIP": "172.17.0.25", + "podIPs": [{ "ip": "172.17.0.25" }], + "startTime": "2021-11-16T02:10:22Z", + "containerStatuses": [ + { + "name": "busybox", + "state": { + "running": { "startedAt": "2021-11-16T02:10:31Z" } + }, + "lastState": {}, + "ready": true, + "restartCount": 0, + "image": "busybox:latest", + "imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353", + "containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615", + "started": true + } + ], + "qosClass": "Burstable" + } + } + ] +} diff --git a/plugins/kubernetes/src/__fixtures__/2-cronjobs.json b/plugins/kubernetes/src/__fixtures__/2-cronjobs.json new file mode 100644 index 0000000000..6c087023b7 --- /dev/null +++ b/plugins/kubernetes/src/__fixtures__/2-cronjobs.json @@ -0,0 +1,385 @@ +{ + "cronJobs": [ + { + "metadata": { + "name": "dice-roller-cronjob", + "namespace": "default", + "uid": "9ea073bc-7a4b-4b99-8321-0305bce85568", + "resourceVersion": "1362732552", + "creationTimestamp": "2021-07-16T22:39:58Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "annotations": {} + }, + "spec": { + "schedule": "* */2 * * *", + "startingDeadlineSeconds": 1800, + "concurrencyPolicy": "Forbid", + "suspend": true, + "jobTemplate": { + "metadata": { "creationTimestamp": null }, + "spec": { + "backoffLimit": 2, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "annotations": {} + }, + "spec": { + "containers": [ + { + "name": "busybox", + "image": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "IfNotPresent", + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"] + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + } + }, + "successfulJobsHistoryLimit": 2, + "failedJobsHistoryLimit": 2 + }, + "status": { + "lastScheduleTime": "2021-11-16T02:10:00Z" + } + } + ], + "jobs": [ + { + "metadata": { + "name": "dice-roller-cronjob-1637025000", + "namespace": "default", + "uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "resourceVersion": "1361029181", + "creationTimestamp": "2021-11-16T01:10:24Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "CronJob", + "name": "dice-roller-cronjob", + "uid": "9ea073bc-7a4b-4b99-8321-0305bce85568", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "parallelism": 1, + "completions": 1, + "backoffLimit": 2, + "selector": { + "matchLabels": { + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "job-name": "dice-roller-cronjob-1637025000" + }, + "annotations": {} + }, + "spec": { + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + }, + "status": { + "conditions": [ + { + "type": "Failed", + "status": "True", + "reason": "BackoffLimitExceeded", + "lastProbeTime": "2021-11-16T01:11:31Z", + "lastTransitionTime": "2021-11-16T01:11:31Z" + } + ], + "startTime": "2021-11-16T01:10:24Z", + "failed": 2 + } + } + ], + "pods": [ + { + "metadata": { + "name": "dice-roller-cronjob-1637025000-gstc4", + "generateName": "dice-roller-cronjob-1637025000-", + "namespace": "default", + "uid": "3b0f2b65-5ae2-441a-beda-bdc92bcafaf0", + "resourceVersion": "1361029179", + "creationTimestamp": "2021-11-16T01:10:24Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "job-name": "dice-roller-cronjob-1637025000" + }, + "annotations": {}, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "Job", + "name": "dice-roller-cronjob-1637025000", + "uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "volumes": [], + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "default", + "serviceAccount": "default", + "nodeName": "minikube", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + } + ], + "priority": 0, + "enableServiceLinks": true + }, + "status": { + "phase": "Failed", + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:10:13Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:11:02Z", + "message": "containers with unready status: [busybox]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:11:02Z", + "message": "containers with unready status: [busybox]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:10:08Z", + "status": "True", + "type": "PodScheduled" + } + ], + "hostIP": "192.168.49.2", + "podIP": "172.17.0.25", + "podIPs": [{ "ip": "172.17.0.25" }], + "startTime": "2021-11-18T19:10:08Z", + "containerStatuses": [ + { + "name": "busybox", + "state": { + "terminated": { + "exitCode": 1, + "finishedAt": "2021-11-18T19:11:01Z", + "reason": "Error", + "startedAt": "2021-11-18T19:10:17Z", + "containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615" + } + }, + "lastState": {}, + "ready": false, + "restartCount": 0, + "image": "busybox:latest", + "imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353", + "containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615", + "started": false + } + ], + "qosClass": "Burstable" + } + }, + { + "metadata": { + "name": "dice-roller-cronjob-1637025000-p4mlc", + "generateName": "dice-roller-cronjob-1637025000-", + "namespace": "default", + "uid": "acddd5d2-ac7f-473b-a9d8-17a89f99ac39", + "resourceVersion": "1361174579", + "creationTimestamp": "2021-11-16T02:10:22Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "job-name": "dice-roller-cronjob-1637025000" + }, + "annotations": {}, + "ownerReferences": [ + { + "apiVersion": "batch/v1", + "kind": "Job", + "name": "dice-roller-cronjob-1637025000", + "uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a", + "controller": true, + "blockOwnerDeletion": true + } + ] + }, + "spec": { + "volumes": [], + "containers": [ + { + "command": ["/bin/sh", "-c", "date; echo Rolling a die!"], + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "busybox", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "restartPolicy": "Never", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "default", + "serviceAccount": "default", + "nodeName": "minikube", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + } + ], + "priority": 0, + "enableServiceLinks": true + }, + "status": { + "phase": "Failed", + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:10:13Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:11:02Z", + "message": "containers with unready status: [busybox]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:11:02Z", + "message": "containers with unready status: [busybox]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-11-18T19:10:08Z", + "status": "True", + "type": "PodScheduled" + } + ], + "hostIP": "192.168.49.2", + "podIP": "172.17.0.25", + "podIPs": [{ "ip": "172.17.0.25" }], + "startTime": "2021-11-16T02:10:22Z", + "containerStatuses": [ + { + "name": "busybox", + "state": { + "terminated": { + "exitCode": 1, + "finishedAt": "2021-11-18T19:11:01Z", + "reason": "Error", + "startedAt": "2021-11-18T19:10:17Z", + "containerID": "docker://2659c4d0f8a68f2b49863c18738322f1686d5b87275428e5e641fd9fd9e06739" + } + }, + "lastState": {}, + "ready": true, + "restartCount": 0, + "image": "busybox:latest", + "imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353", + "containerID": "docker://2659c4d0f8a68f2b49863c18738322f1686d5b87275428e5e641fd9fd9e06739", + "started": true + } + ], + "qosClass": "Burstable" + } + } + ] +} diff --git a/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx b/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx index ddadd95272..620af05e69 100644 --- a/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx +++ b/plugins/kubernetes/src/components/Cluster/Cluster.test.tsx @@ -46,6 +46,7 @@ describe('Cluster', () => { resources: oneDeployment.pods, }, ], + podMetrics: [], errors: [], }, podsWithErrors: new Set(), diff --git a/plugins/kubernetes/src/components/Cluster/Cluster.tsx b/plugins/kubernetes/src/components/Cluster/Cluster.tsx index d238396f53..b7a819cdbf 100644 --- a/plugins/kubernetes/src/components/Cluster/Cluster.tsx +++ b/plugins/kubernetes/src/components/Cluster/Cluster.tsx @@ -23,12 +23,16 @@ import { Grid, Typography, } from '@material-ui/core'; -import { ClusterObjects } from '@backstage/plugin-kubernetes-common'; +import { + ClientPodStatus, + ClusterObjects, +} from '@backstage/plugin-kubernetes-common'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { DeploymentsAccordions } from '../DeploymentsAccordions'; import { groupResponses } from '../../utils/response'; import { IngressesAccordions } from '../IngressesAccordions'; import { ServicesAccordions } from '../ServicesAccordions'; +import { CronJobsAccordions } from '../CronJobsAccordions'; import { CustomResources } from '../CustomResources'; import { ClusterContext, @@ -37,6 +41,7 @@ import { } from '../../hooks'; import { StatusError, StatusOK } from '@backstage/core-components'; +import { PodNamesWithMetricsContext } from '../../hooks/PodNamesWithMetrics'; type ClusterSummaryProps = { clusterName: string; @@ -107,36 +112,50 @@ type ClusterProps = { export const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => { const groupedResponses = groupResponses(clusterObjects.resources); + const podNameToMetrics = clusterObjects.podMetrics + .flat() + .reduce((accum, next) => { + const name = next.pod.metadata?.name; + if (name !== undefined) { + accum.set(name, next); + } + return accum; + }, new Map()); return ( - - - }> - - - - - - + + + + }> + + + + + + + + + + + + + + + + - + - - - - - - - - - - + + + + ); diff --git a/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.test.tsx b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.test.tsx new file mode 100644 index 0000000000..9d2df328d2 --- /dev/null +++ b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { CronJobsAccordions } from './CronJobsAccordions'; +import * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json'; +import * as twoCronJobsFixture from '../../__fixtures__/2-cronjobs.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { kubernetesProviders } from '../../hooks/test-utils'; + +describe('CronJobsAccordions', () => { + it('should render 1 active cronjobs', async () => { + const wrapper = kubernetesProviders(oneCronJobsFixture, new Set()); + + const { getByText } = render( + wrapper(wrapInTestApp()), + ); + + expect(getByText('dice-roller-cronjob')).toBeInTheDocument(); + expect(getByText('CronJob')).toBeInTheDocument(); + expect(getByText('namespace: default')).toBeInTheDocument(); + expect(getByText('Active')).toBeInTheDocument(); + }); + + it('should render 1 suspended cronjobs', async () => { + const wrapper = kubernetesProviders(twoCronJobsFixture, new Set()); + + const { getByText } = render( + wrapper(wrapInTestApp()), + ); + + expect(getByText('dice-roller-cronjob')).toBeInTheDocument(); + expect(getByText('CronJob')).toBeInTheDocument(); + expect(getByText('namespace: default')).toBeInTheDocument(); + expect(getByText('Suspended')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.tsx b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.tsx new file mode 100644 index 0000000000..2581678928 --- /dev/null +++ b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsAccordions.tsx @@ -0,0 +1,128 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, + Typography, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { V1CronJob, V1Job } from '@kubernetes/client-node'; +import { JobsAccordions } from '../JobsAccordions'; +import { CronJobDrawer } from './CronJobsDrawer'; +import { getOwnedResources } from '../../utils/owner'; +import { GroupedResponsesContext } from '../../hooks'; +import { StatusError, StatusOK } from '@backstage/core-components'; +import cronstrue from 'cronstrue'; + +type CronJobsAccordionsProps = { + children?: React.ReactNode; +}; + +type CronJobAccordionProps = { + cronJob: V1CronJob; + ownedJobs: V1Job[]; + children?: React.ReactNode; +}; + +type CronJobSummaryProps = { + cronJob: V1CronJob; + children?: React.ReactNode; +}; + +const CronJobSummary = ({ cronJob }: CronJobSummaryProps) => { + return ( + + + + + + + + + + {cronJob.spec?.suspend ? ( + Suspended + ) : ( + Active + )} + + + + Schedule:{' '} + {cronJob.spec?.schedule + ? `${cronJob.spec.schedule} (${cronstrue.toString( + cronJob.spec.schedule, + )})` + : 'N/A'} + + + + + ); +}; + +const CronJobAccordion = ({ cronJob, ownedJobs }: CronJobAccordionProps) => { + return ( + + }> + + + + + + + ); +}; + +export const CronJobsAccordions = ({}: CronJobsAccordionsProps) => { + const groupedResponses = useContext(GroupedResponsesContext); + + return ( + + {groupedResponses.cronJobs.map((cronJob, i) => ( + + + + + + ))} + + ); +}; diff --git a/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.test.tsx b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.test.tsx new file mode 100644 index 0000000000..b16e83ee50 --- /dev/null +++ b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json'; +import { renderInTestApp } from '@backstage/test-utils'; +import { CronJobDrawer } from './CronJobsDrawer'; + +describe('CronJobDrawer', () => { + it('should render cronJob drawer', async () => { + const { getByText, getAllByText } = await renderInTestApp( + , + ); + + expect(getAllByText('dice-roller-cronjob')).toHaveLength(2); + expect(getAllByText('CronJob')).toHaveLength(2); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Schedule')).toBeInTheDocument(); + expect(getByText('30 5 * * *')).toBeInTheDocument(); + expect(getByText('Starting Deadline Seconds')).toBeInTheDocument(); + expect(getByText('Last Schedule Time')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.tsx b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.tsx new file mode 100644 index 0000000000..758f17ade0 --- /dev/null +++ b/plugins/kubernetes/src/components/CronJobsAccordions/CronJobsDrawer.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { V1CronJob } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; +import { Typography, Grid, Chip } from '@material-ui/core'; + +export const CronJobDrawer = ({ + cronJob, + expanded, +}: { + cronJob: V1CronJob; + expanded?: boolean; +}) => { + const namespace = cronJob.metadata?.namespace; + return ( + ({ + schedule: cronJobObj.spec?.schedule ?? '???', + startingDeadlineSeconds: + cronJobObj.spec?.startingDeadlineSeconds ?? '???', + concurrencyPolicy: cronJobObj.spec?.concurrencyPolicy ?? '???', + lastScheduleTime: cronJobObj.status?.lastScheduleTime ?? '???', + })} + > + + + + {cronJob.metadata?.name ?? 'unknown object'} + + + + + CronJob + + + {namespace && ( + + + + )} + + + ); +}; diff --git a/plugins/kubernetes/src/components/CronJobsAccordions/index.ts b/plugins/kubernetes/src/components/CronJobsAccordions/index.ts new file mode 100644 index 0000000000..e72e3f7e6d --- /dev/null +++ b/plugins/kubernetes/src/components/CronJobsAccordions/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { CronJobsAccordions } from './CronJobsAccordions'; diff --git a/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx b/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx index e14cd9093a..52734e39a8 100644 --- a/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx +++ b/plugins/kubernetes/src/components/CustomResources/ArgoRollouts/Rollout.tsx @@ -41,6 +41,7 @@ import { getOwnedPodsThroughReplicaSets, } from '../../../utils/owner'; import { StatusError, StatusOK } from '@backstage/core-components'; +import { READY_COLUMNS, RESOURCE_COLUMNS } from '../../Pods/PodsTable'; type RolloutAccordionsProps = { rollouts: any[]; @@ -237,7 +238,10 @@ const RolloutAccordion = ({ />
- +
diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx index e27c2bfbe1..58ab2fc2ba 100644 --- a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx @@ -41,6 +41,7 @@ import { PodNamesWithErrorsContext, } from '../../hooks'; import { StatusError, StatusOK } from '@backstage/core-components'; +import { READY_COLUMNS, RESOURCE_COLUMNS } from '../Pods/PodsTable'; type DeploymentsAccordionsProps = { children?: React.ReactNode; @@ -161,7 +162,10 @@ const DeploymentAccordion = ({ /> - + ); diff --git a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx index 02154c4825..832d6eed91 100644 --- a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx +++ b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx @@ -51,6 +51,7 @@ describe('ErrorPanel', () => { name: 'THIS_CLUSTER', }, resources: [], + podMetrics: [], errors: [ { errorType: 'SYSTEM_ERROR', diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx index 0018c4b070..dc88987d53 100644 --- a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { V1Ingress } from '@kubernetes/client-node'; import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; import { Typography, Grid } from '@material-ui/core'; @@ -23,7 +23,7 @@ export const IngressDrawer = ({ ingress, expanded, }: { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; expanded?: boolean; }) => { return ( @@ -31,7 +31,7 @@ export const IngressDrawer = ({ object={ingress} expanded={expanded} kind="Ingress" - renderObject={(ingressObject: ExtensionsV1beta1Ingress) => { + renderObject={(ingressObject: V1Ingress) => { return ingressObject.spec || {}; }} > diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx index c1d94fef7b..f6d6e4bf68 100644 --- a/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx @@ -23,7 +23,7 @@ import { Grid, } from '@material-ui/core'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { V1Ingress } from '@kubernetes/client-node'; import { IngressDrawer } from './IngressDrawer'; import { GroupedResponsesContext } from '../../hooks'; import { StructuredMetadataTable } from '@backstage/core-components'; @@ -31,11 +31,11 @@ import { StructuredMetadataTable } from '@backstage/core-components'; type IngressesAccordionsProps = {}; type IngressAccordionProps = { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; }; type IngressSummaryProps = { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; }; const IngressSummary = ({ ingress }: IngressSummaryProps) => { @@ -58,7 +58,7 @@ const IngressSummary = ({ ingress }: IngressSummaryProps) => { }; type IngressCardProps = { - ingress: ExtensionsV1beta1Ingress; + ingress: V1Ingress; }; const IngressCard = ({ ingress }: IngressCardProps) => { diff --git a/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.test.tsx b/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.test.tsx new file mode 100644 index 0000000000..ea16e933c6 --- /dev/null +++ b/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { JobsAccordions } from './JobsAccordions'; +import * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { kubernetesProviders } from '../../hooks/test-utils'; +import { V1Job, ObjectSerializer } from '@kubernetes/client-node'; + +describe('JobsAccordions', () => { + it('should render 2 jobs', async () => { + const wrapper = kubernetesProviders(oneCronJobsFixture, new Set()); + + const jobs: V1Job[] = oneCronJobsFixture.jobs.map( + job => ObjectSerializer.deserialize(job, 'V1Job') as V1Job, + ); + + const { getByText } = render( + wrapper(wrapInTestApp()), + ); + + expect(getByText('dice-roller-cronjob-1637028600')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + + expect(getByText('dice-roller-cronjob-1637025000')).toBeInTheDocument(); + expect(getByText('Succeeded')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.tsx b/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.tsx new file mode 100644 index 0000000000..715be4c88a --- /dev/null +++ b/plugins/kubernetes/src/components/JobsAccordions/JobsAccordions.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { V1Job, V1Pod } from '@kubernetes/client-node'; +import { PodsTable } from '../Pods'; +import { JobDrawer } from './JobsDrawer'; +import { getOwnedResources } from '../../utils/owner'; +import { GroupedResponsesContext } from '../../hooks'; +import { + StatusError, + StatusOK, + StatusPending, +} from '@backstage/core-components'; + +type JobsAccordionsProps = { + jobs: V1Job[]; + children?: React.ReactNode; +}; + +type JobAccordionProps = { + job: V1Job; + ownedPods: V1Pod[]; + children?: React.ReactNode; +}; + +type JobSummaryProps = { + job: V1Job; + children?: React.ReactNode; +}; + +const JobSummary = ({ job }: JobSummaryProps) => { + return ( + + + + + + + + + + {job.status?.succeeded && Succeeded} + {job.status?.active && Running} + {job.status?.failed && Failed} + + Start time: {job.status?.startTime?.toString()} + {job.status?.completionTime && ( + + Completion time: {job.status.completionTime.toString()} + + )} + + + ); +}; + +const JobAccordion = ({ job, ownedPods }: JobAccordionProps) => { + return ( + + }> + + + + + + + ); +}; + +export const JobsAccordions = ({ jobs }: JobsAccordionsProps) => { + const groupedResponses = useContext(GroupedResponsesContext); + + return ( + + {jobs.map((job, i) => ( + + + + + + ))} + + ); +}; diff --git a/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.test.tsx b/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.test.tsx new file mode 100644 index 0000000000..48a4e6fe7e --- /dev/null +++ b/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json'; +import { renderInTestApp } from '@backstage/test-utils'; +import { JobDrawer } from './JobsDrawer'; + +describe('JobDrawer', () => { + it('should render job drawer', async () => { + const { getByText, getAllByText } = await renderInTestApp( + , + ); + + expect(getAllByText('dice-roller-cronjob-1637025000')).toHaveLength(2); + expect(getAllByText('Job')).toHaveLength(2); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Parallelism')).toBeInTheDocument(); + expect(getByText('Completions')).toBeInTheDocument(); + expect(getByText('Backoff Limit')).toBeInTheDocument(); + expect(getByText('Start Time')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.tsx b/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.tsx new file mode 100644 index 0000000000..a7217725b2 --- /dev/null +++ b/plugins/kubernetes/src/components/JobsAccordions/JobsDrawer.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { V1Job } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; +import { Typography, Grid } from '@material-ui/core'; + +export const JobDrawer = ({ + job, + expanded, +}: { + job: V1Job; + expanded?: boolean; +}) => { + return ( + { + return { + parallelism: jobObj.spec?.parallelism ?? '???', + completions: jobObj.spec?.completions ?? '???', + backoffLimit: jobObj.spec?.backoffLimit ?? '???', + startTime: jobObj.status?.startTime ?? '???', + }; + }} + > + + + + {job.metadata?.name ?? 'unknown object'} + + + + + Job + + + + + ); +}; diff --git a/plugins/kubernetes/src/components/JobsAccordions/index.ts b/plugins/kubernetes/src/components/JobsAccordions/index.ts new file mode 100644 index 0000000000..392309de79 --- /dev/null +++ b/plugins/kubernetes/src/components/JobsAccordions/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { JobsAccordions } from './JobsAccordions'; diff --git a/plugins/kubernetes/src/components/KubernetesContent.test.tsx b/plugins/kubernetes/src/components/KubernetesContent.test.tsx index db8cd06782..231eb3b37a 100644 --- a/plugins/kubernetes/src/components/KubernetesContent.test.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent.test.tsx @@ -73,6 +73,7 @@ describe('KubernetesContent', () => { resources: oneDeployment.pods, }, ], + podMetrics: [], errors: [], }, ], @@ -121,6 +122,7 @@ describe('KubernetesContent', () => { resources: twoDeployments.pods, }, ], + podMetrics: [], errors: [], }, { @@ -139,6 +141,7 @@ describe('KubernetesContent', () => { resources: oneDeployment.pods, }, ], + podMetrics: [], errors: [], }, ], diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx index 7c6c7e97f7..1201ad106d 100644 --- a/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx +++ b/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx @@ -19,7 +19,9 @@ import { render } from '@testing-library/react'; import * as pod from './__fixtures__/pod.json'; import * as crashingPod from './__fixtures__/crashing-pod.json'; import { wrapInTestApp } from '@backstage/test-utils'; -import { PodsTable } from './PodsTable'; +import { PodsTable, READY_COLUMNS, RESOURCE_COLUMNS } from './PodsTable'; +import { kubernetesProviders } from '../../hooks/test-utils'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; describe('PodsTable', () => { it('should render pod', async () => { @@ -27,6 +29,24 @@ describe('PodsTable', () => { wrapInTestApp(), ); + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('status')).toBeInTheDocument(); + + // values + expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getByText('OK')).toBeInTheDocument(); + }); + + it('should render pod with extra columns', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + // titles expect(getByText('name')).toBeInTheDocument(); expect(getByText('phase')).toBeInTheDocument(); @@ -41,9 +61,102 @@ describe('PodsTable', () => { expect(getByText('0')).toBeInTheDocument(); expect(getByText('OK')).toBeInTheDocument(); }); - it('should render crashing pod', async () => { + it('should render pod, with metrics context', async () => { + const podNameToClientPodStatus = new Map(); + + podNameToClientPodStatus.set('dice-roller-6c8646bfd-2m5hv', { + memory: { + currentUsage: '1069056', + requestTotal: '67108864', + limitTotal: '134217728', + }, + cpu: { + currentUsage: 0.4966115, + requestTotal: 0.05, + limitTotal: 0.05, + }, + } as any); + + const wrapper = kubernetesProviders( + undefined, + undefined, + podNameToClientPodStatus, + ); + const { getByText } = render( + wrapper( + wrapInTestApp( + , + ), + ), + ); + + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('containers ready')).toBeInTheDocument(); + expect(getByText('total restarts')).toBeInTheDocument(); + expect(getByText('status')).toBeInTheDocument(); + expect(getByText('CPU usage %')).toBeInTheDocument(); + expect(getByText('Memory usage %')).toBeInTheDocument(); + + // values + expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getByText('1/1')).toBeInTheDocument(); + expect(getByText('0')).toBeInTheDocument(); + expect(getByText('OK')).toBeInTheDocument(); + expect(getByText('requests: 99%')).toBeInTheDocument(); + expect(getByText('limits: 99%')).toBeInTheDocument(); + expect(getByText('requests: 1%')).toBeInTheDocument(); + expect(getByText('limits: 0%')).toBeInTheDocument(); + }); + it('should render placehoplder when empty metrics context', async () => { + const podNameToClientPodStatus = new Map(); + + const wrapper = kubernetesProviders( + undefined, + undefined, + podNameToClientPodStatus, + ); const { getByText, getAllByText } = render( - wrapInTestApp(), + wrapper( + wrapInTestApp( + , + ), + ), + ); + + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('containers ready')).toBeInTheDocument(); + expect(getByText('total restarts')).toBeInTheDocument(); + expect(getByText('status')).toBeInTheDocument(); + expect(getByText('CPU usage %')).toBeInTheDocument(); + expect(getByText('Memory usage %')).toBeInTheDocument(); + + // values + expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getByText('1/1')).toBeInTheDocument(); + expect(getByText('0')).toBeInTheDocument(); + expect(getByText('OK')).toBeInTheDocument(); + expect(getAllByText('unknown')).toHaveLength(2); + }); + it('should render crashing pod with extra columns', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp( + , + ), ); // titles diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.tsx index 7b5497ff37..1d2369e322 100644 --- a/plugins/kubernetes/src/components/Pods/PodsTable.tsx +++ b/plugins/kubernetes/src/components/Pods/PodsTable.tsx @@ -14,17 +14,30 @@ * limitations under the License. */ -import React from 'react'; +import React, { useContext } from 'react'; import { V1Pod } from '@kubernetes/client-node'; import { PodDrawer } from './PodDrawer'; import { containersReady, containerStatuses, + podStatusToCpuUtil, + podStatusToMemoryUtil, totalRestarts, } from '../../utils/pod'; import { Table, TableColumn } from '@backstage/core-components'; +import { PodNamesWithMetricsContext } from '../../hooks/PodNamesWithMetrics'; -const columns: TableColumn[] = [ +export const READY_COLUMNS: PodColumns = 'READY'; +export const RESOURCE_COLUMNS: PodColumns = 'RESOURCE'; +export type PodColumns = 'READY' | 'RESOURCE'; + +type PodsTablesProps = { + pods: V1Pod[]; + extraColumns?: PodColumns[]; + children?: React.ReactNode; +}; + +const DEFAULT_COLUMNS: TableColumn[] = [ { title: 'name', highlight: true, @@ -34,6 +47,13 @@ const columns: TableColumn[] = [ title: 'phase', render: (pod: V1Pod) => pod.status?.phase ?? 'unknown', }, + { + title: 'status', + render: containerStatuses, + }, +]; + +const READY: TableColumn[] = [ { title: 'containers ready', align: 'center', @@ -45,18 +65,45 @@ const columns: TableColumn[] = [ render: totalRestarts, type: 'numeric', }, - { - title: 'status', - render: containerStatuses, - }, ]; -type DeploymentTablesProps = { - pods: V1Pod[]; - children?: React.ReactNode; -}; +export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => { + const podNamesWithMetrics = useContext(PodNamesWithMetricsContext); + const columns: TableColumn[] = [...DEFAULT_COLUMNS]; + + if (extraColumns.includes(READY_COLUMNS)) { + columns.push(...READY); + } + if (extraColumns.includes(RESOURCE_COLUMNS)) { + const resourceColumns: TableColumn[] = [ + { + title: 'CPU usage %', + render: (pod: V1Pod) => { + const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? ''); + + if (!metrics) { + return 'unknown'; + } + + return podStatusToCpuUtil(metrics); + }, + }, + { + title: 'Memory usage %', + render: (pod: V1Pod) => { + const metrics = podNamesWithMetrics.get(pod.metadata?.name ?? ''); + + if (!metrics) { + return 'unknown'; + } + + return podStatusToMemoryUtil(metrics); + }, + }, + ]; + columns.push(...resourceColumns); + } -export const PodsTable = ({ pods }: DeploymentTablesProps) => { const tableStyle = { minWidth: '0', width: '100%', diff --git a/plugins/kubernetes/src/error-detection/error-detection.test.ts b/plugins/kubernetes/src/error-detection/error-detection.test.ts index 2e982f25f7..dcac7db907 100644 --- a/plugins/kubernetes/src/error-detection/error-detection.test.ts +++ b/plugins/kubernetes/src/error-detection/error-detection.test.ts @@ -40,6 +40,7 @@ const oneItem = (value: FetchResponse): ObjectsByEntityResponse => { { cluster: { name: CLUSTER_NAME }, errors: [], + podMetrics: [], resources: [value], }, ], @@ -74,6 +75,7 @@ describe('detectErrors', () => { { cluster: { name: 'cluster-a' }, errors: [], + podMetrics: [], resources: [ { type: 'pods', @@ -84,6 +86,7 @@ describe('detectErrors', () => { { cluster: { name: 'cluster-b' }, errors: [], + podMetrics: [], resources: [ { type: 'horizontalpodautoscalers', @@ -94,6 +97,7 @@ describe('detectErrors', () => { { cluster: { name: 'cluster-c' }, errors: [], + podMetrics: [], resources: [ { type: 'deployments', diff --git a/plugins/kubernetes/src/hooks/GroupedResponses.ts b/plugins/kubernetes/src/hooks/GroupedResponses.ts index d000086b7c..35f7e10b5c 100644 --- a/plugins/kubernetes/src/hooks/GroupedResponses.ts +++ b/plugins/kubernetes/src/hooks/GroupedResponses.ts @@ -24,5 +24,7 @@ export const GroupedResponsesContext = React.createContext({ configMaps: [], horizontalPodAutoscalers: [], ingresses: [], + jobs: [], + cronJobs: [], customResources: [], }); diff --git a/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts b/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts new file mode 100644 index 0000000000..66a833ad88 --- /dev/null +++ b/plugins/kubernetes/src/hooks/PodNamesWithMetrics.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; + +export const PodNamesWithMetricsContext = React.createContext< + Map +>(new Map()); diff --git a/plugins/kubernetes/src/hooks/test-utils.tsx b/plugins/kubernetes/src/hooks/test-utils.tsx index c5b7a8f5d9..a7a6f42512 100644 --- a/plugins/kubernetes/src/hooks/test-utils.tsx +++ b/plugins/kubernetes/src/hooks/test-utils.tsx @@ -17,17 +17,25 @@ import React from 'react'; import { GroupedResponsesContext } from './GroupedResponses'; import { PodNamesWithErrorsContext } from './PodNamesWithErrors'; +import { PodNamesWithMetricsContext } from './PodNamesWithMetrics'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; export const kubernetesProviders = ( - groupedResponses: any, - podsWithErrors: any, + groupedResponses: any = undefined, + podsWithErrors: Set = new Set(), + podNameToMetrics: Map = new Map< + string, + ClientPodStatus + >(), ) => { return (node: React.ReactNode) => ( <> - - {node} - + + + {node} + + ); diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts index 3f2d8c6a87..a1f2f57621 100644 --- a/plugins/kubernetes/src/plugin.ts +++ b/plugins/kubernetes/src/plugin.ts @@ -28,8 +28,7 @@ import { } from '@backstage/core-plugin-api'; export const rootCatalogKubernetesRouteRef = createRouteRef({ - path: '*', - title: 'Kubernetes', + id: 'kubernetes', }); export const kubernetesPlugin = createPlugin({ diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts index 05337086be..d6ef8fa784 100644 --- a/plugins/kubernetes/src/types/types.ts +++ b/plugins/kubernetes/src/types/types.ts @@ -21,7 +21,9 @@ import { V1HorizontalPodAutoscaler, V1Service, V1ConfigMap, - ExtensionsV1beta1Ingress, + V1Ingress, + V1Job, + V1CronJob, } from '@kubernetes/client-node'; export interface DeploymentResources { @@ -34,7 +36,9 @@ export interface DeploymentResources { export interface GroupedResponses extends DeploymentResources { services: V1Service[]; configMaps: V1ConfigMap[]; - ingresses: ExtensionsV1beta1Ingress[]; + ingresses: V1Ingress[]; + jobs: V1Job[]; + cronJobs: V1CronJob[]; customResources: any[]; } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts index afc53d9509..31e07a0f14 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts @@ -78,21 +78,6 @@ describe('clusterLinks', () => { 'https://k8s.foo.com/#/service/bar/foobar?namespace=bar', ); }); - it('should return an url on the deployment properly url encoded', () => { - const url = formatClusterLink({ - dashboardUrl: 'https://k8s.foo.com/', - object: { - metadata: { - name: 'foobar', - namespace: 'bar bar', - }, - }, - kind: 'Deployment', - }); - expect(url).toBe( - 'https://k8s.foo.com/#/deployment/bar%20bar/foobar?namespace=bar+bar', - ); - }); }); describe('standard app', () => { diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts index 83e970248b..a8f83a9c76 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts @@ -40,8 +40,5 @@ export function formatClusterLink(options: FormatClusterLinkOptions) { object: options.object, kind: options.kind, }); - // Note that we can't rely on 'url.href' since it will put the search before the hash - // and this won't be properly recognized by SPAs such as Angular in the standard dashboard. - // Note also that pathname, hash and search will be properly url encoded. - return `${url.origin}${url.pathname}${url.hash}${url.search}`; + return url.toString(); } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts index df2529a8ac..f0b85cad49 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts @@ -17,19 +17,126 @@ import { openshiftFormatter } from './openshift'; describe('clusterLinks - OpenShift formatter', () => { it('should return an url on the workloads when there is a namespace only', () => { - expect(() => - openshiftFormatter({ - dashboardUrl: new URL('https://k8s.foo.com'), - object: { - metadata: { - name: 'foobar', - namespace: 'bar', - }, + const url = openshiftFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + namespace: 'bar', }, - kind: 'Deployment', - }), - ).toThrowError( - 'OpenShift formatter is not yet implemented. Please, contribute!', + }, + kind: 'foo', + }); + expect(url.href).toBe('https://k8s.foo.com/k8s/cluster/projects/bar'); + }); + it('should return an url on the workloads when the kind is not recognizeed', () => { + const url = openshiftFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'UnknownKind', + }); + expect(url.href).toBe('https://k8s.foo.com/k8s/cluster/projects/bar'); + }); + it('should return an url on the deployment', () => { + const url = openshiftFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(url.href).toBe('https://k8s.foo.com/k8s/ns/bar/deployments/foobar'); + }); + it('should return an url on the deployment and keep the path prefix 1', () => { + const url = openshiftFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/some/prefix/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(url.href).toBe( + 'https://k8s.foo.com/some/prefix/k8s/ns/bar/deployments/foobar', + ); + }); + it('should return an url on the deployment and keep the path prefix 2', () => { + const url = openshiftFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/some/prefix'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(url.href).toBe( + 'https://k8s.foo.com/some/prefix/k8s/ns/bar/deployments/foobar', + ); + }); + it('should return an url on the service', () => { + const url = openshiftFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Service', + }); + expect(url.href).toBe('https://k8s.foo.com/k8s/ns/bar/services/foobar'); + }); + it('should return an url on the ingress', () => { + const url = openshiftFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Ingress', + }); + expect(url.href).toBe('https://k8s.foo.com/k8s/ns/bar/ingresses/foobar'); + }); + it('should return an url on the deployment for a hpa', () => { + const url = openshiftFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'HorizontalPodAutoscaler', + }); + expect(url.href).toBe( + 'https://k8s.foo.com/k8s/ns/bar/horizontalpodautoscalers/foobar', + ); + }); + it('should return an url on the PV', () => { + const url = openshiftFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + }, + }, + kind: 'PersistentVolume', + }); + expect(url.href).toBe( + 'https://k8s.foo.com/k8s/cluster/persistentvolumes/foobar', ); }); }); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts index bacb747ebb..6c20cd4720 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts @@ -15,10 +15,40 @@ */ import { ClusterLinksFormatterOptions } from '../../../types/types'; -export function openshiftFormatter( - _options: ClusterLinksFormatterOptions, -): URL { - throw new Error( - 'OpenShift formatter is not yet implemented. Please, contribute!', +const kindMappings: Record = { + deployment: 'deployments', + ingress: 'ingresses', + service: 'services', + horizontalpodautoscaler: 'horizontalpodautoscalers', + persistentvolume: 'persistentvolumes', +}; + +export function openshiftFormatter(options: ClusterLinksFormatterOptions): URL { + const basePath = new URL(options.dashboardUrl.href); + const name = encodeURIComponent(options.object.metadata?.name ?? ''); + const namespace = encodeURIComponent( + options.object.metadata?.namespace ?? '', ); + const validKind = kindMappings[options.kind.toLocaleLowerCase('en-US')]; + if (!basePath.pathname.endsWith('/')) { + // a dashboard url with a path should end with a slash otherwise + // the new combined URL will replace the last segment with the appended path! + // https://foobar.com/abc/def + k8s/cluster/projects/test --> https://foobar.com/abc/k8s/cluster/projects/test + // https://foobar.com/abc/def/ + k8s/cluster/projects/test --> https://foobar.com/abc/def/k8s/cluster/projects/test + basePath.pathname += '/'; + } + let path = ''; + if (namespace) { + if (name && validKind) { + path = `k8s/ns/${namespace}/${validKind}/${name}`; + } else { + path = `k8s/cluster/projects/${namespace}`; + } + } else if (validKind) { + path = `k8s/cluster/${validKind}`; + if (name) { + path += `/${name}`; + } + } + return new URL(path, basePath); } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts index a3a274496e..1ace39ec59 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts @@ -23,14 +23,24 @@ const kindMappings: Record = { }; export function rancherFormatter(options: ClusterLinksFormatterOptions): URL { - const result = new URL(options.dashboardUrl.href); - const name = options.object.metadata?.name; - const namespace = options.object.metadata?.namespace; + const basePath = new URL(options.dashboardUrl.href); + const name = encodeURIComponent(options.object.metadata?.name ?? ''); + const namespace = encodeURIComponent( + options.object.metadata?.namespace ?? '', + ); const validKind = kindMappings[options.kind.toLocaleLowerCase('en-US')]; - if (validKind && name && namespace) { - result.pathname += `explorer/${validKind}/${namespace}/${name}`; - } else if (namespace) { - result.pathname += 'explorer/workload'; + if (!basePath.pathname.endsWith('/')) { + // a dashboard url with a path should end with a slash otherwise + // the new combined URL will replace the last segment with the appended path! + // https://foobar.com/abc/def + explorer/service/test --> https://foobar.com/abc/explorer/service/test + // https://foobar.com/abc/def/ + explorer/service/test --> https://foobar.com/abc/def/explorer/service/test + basePath.pathname += '/'; } - return result; + let path = ''; + if (validKind && name && namespace) { + path = `explorer/${validKind}/${namespace}/${name}`; + } else if (namespace) { + path = 'explorer/workload'; + } + return new URL(path, basePath); } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts index 76bbf5643d..0b3c21a627 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts @@ -67,6 +67,51 @@ describe('clusterLinks - standard formatter', () => { 'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar', ); }); + it('should return an url on the deployment with a prefix 1', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/some/prefix'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/some/prefix/#/deployment/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the deployment with a prefix 2', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/some/prefix/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/some/prefix/#/deployment/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the deployment properly url encoded', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar bar', + }, + }, + kind: 'Deployment', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/deployment/bar%20bar/foobar?namespace=bar%20bar', + ); + }); it('should return an url on the service', () => { const url = standardFormatter({ dashboardUrl: new URL('https://k8s.foo.com/'), diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts index fb26cf220d..e28c9fae2b 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts @@ -24,16 +24,22 @@ const kindMappings: Record = { export function standardFormatter(options: ClusterLinksFormatterOptions) { const result = new URL(options.dashboardUrl.href); - const name = options.object.metadata?.name; - const namespace = options.object.metadata?.namespace; + const name = encodeURIComponent(options.object.metadata?.name ?? ''); + const namespace = encodeURIComponent( + options.object.metadata?.namespace ?? '', + ); const validKind = kindMappings[options.kind.toLocaleLowerCase('en-US')]; - if (namespace) { - result.searchParams.set('namespace', namespace); + if (!result.pathname.endsWith('/')) { + result.pathname += '/'; } if (validKind && name && namespace) { result.hash = `/${validKind}/${namespace}/${name}`; } else if (namespace) { result.hash = '/workloads'; } + if (namespace) { + // Note that Angular SPA requires a hash and the query parameter should be part of it + result.hash += `?namespace=${namespace}`; + } return result; } diff --git a/plugins/kubernetes/src/utils/pod.test.tsx b/plugins/kubernetes/src/utils/pod.test.tsx new file mode 100644 index 0000000000..5822a16b86 --- /dev/null +++ b/plugins/kubernetes/src/utils/pod.test.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { currentToDeclaredResourceToPerc, podStatusToCpuUtil } from './pod'; +import { SubvalueCell } from '@backstage/core-components'; + +describe('pod', () => { + describe('currentToDeclaredResourceToPerc', () => { + it('10%', () => { + const tests: (number | string)[][] = [ + [10, 100], + [10, '100'], + ['10', 100], + ['10', '100'], + ]; + tests.forEach(([a, b]) => { + const result = currentToDeclaredResourceToPerc(a, b); + expect(result).toBe('10%'); + }); + }); + }); + describe('podStatusToCpuUtil', () => { + it('does use correct units', () => { + const result = podStatusToCpuUtil({ + cpu: { + // ~50m + currentUsage: 0.4966115, + // 50m + requestTotal: 0.05, + // 100m + limitTotal: 0.1, + }, + } as any); + expect(result).toStrictEqual( + , + ); + }); + }); +}); diff --git a/plugins/kubernetes/src/utils/pod.tsx b/plugins/kubernetes/src/utils/pod.tsx index 8be2d839c3..bfb40750cd 100644 --- a/plugins/kubernetes/src/utils/pod.tsx +++ b/plugins/kubernetes/src/utils/pod.tsx @@ -14,16 +14,20 @@ * limitations under the License. */ -import { V1Pod, V1PodCondition } from '@kubernetes/client-node'; +import { + V1Pod, + V1PodCondition, + V1DeploymentCondition, +} from '@kubernetes/client-node'; import React, { Fragment, ReactNode } from 'react'; import { Chip } from '@material-ui/core'; -import { V1DeploymentCondition } from '@kubernetes/client-node/dist/gen/model/v1DeploymentCondition'; import { StatusAborted, StatusError, StatusOK, SubvalueCell, } from '@backstage/core-components'; +import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; export const imageChips = (pod: V1Pod): ReactNode => { const containerStatuses = pod.status?.containerStatuses ?? []; @@ -102,3 +106,62 @@ export const renderCondition = ( } return [condition.type, ]; }; + +// visible for testing +export const currentToDeclaredResourceToPerc = ( + current: number | string, + resource: number | string, +): string => { + if (typeof current === 'number' && typeof resource === 'number') { + return `${Math.round((current / resource) * 100)}%`; + } + + const numerator: bigint = BigInt(current); + const denominator: bigint = BigInt(resource); + + return `${(numerator * BigInt(100)) / denominator}%`; +}; + +export const podStatusToCpuUtil = (podStatus: ClientPodStatus): ReactNode => { + const cpuUtil = podStatus.cpu; + + let currentUsage: number | string = cpuUtil.currentUsage; + + // current usage number for CPU is a different unit than request/limit total + // this might be a bug in the k8s library + if (typeof cpuUtil.currentUsage === 'number') { + currentUsage = cpuUtil.currentUsage / 10; + } + + return ( + + ); +}; + +export const podStatusToMemoryUtil = ( + podStatus: ClientPodStatus, +): ReactNode => { + const memUtil = podStatus.memory; + + return ( + + ); +}; diff --git a/plugins/kubernetes/src/utils/response.ts b/plugins/kubernetes/src/utils/response.ts index 97501bb102..cfe3d20580 100644 --- a/plugins/kubernetes/src/utils/response.ts +++ b/plugins/kubernetes/src/utils/response.ts @@ -45,6 +45,12 @@ export const groupResponses = ( case 'ingresses': prev.ingresses.push(...next.resources); break; + case 'jobs': + prev.jobs.push(...next.resources); + break; + case 'cronjobs': + prev.cronJobs.push(...next.resources); + break; case 'customresources': prev.customResources.push(...next.resources); break; @@ -60,6 +66,8 @@ export const groupResponses = ( configMaps: [], horizontalPodAutoscalers: [], ingresses: [], + jobs: [], + cronJobs: [], customResources: [], } as GroupedResponses, ); diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md index 4ff76d4f1b..48ef9a178d 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-lighthouse +## 0.2.30 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.29 ### Patch Changes diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index f0dca639fc..cbd5db27b4 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-lighthouse", "description": "A Backstage plugin that integrates towards Lighthouse", - "version": "0.2.29", + "version": "0.2.30", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,12 +32,12 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -47,10 +47,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx index 1433a186fb..53de33d21b 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx @@ -30,8 +30,9 @@ import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; import * as data from '../../__fixtures__/website-list-response.json'; import { AuditListForEntity } from './AuditListForEntity'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; +import { TestApiRegistry } from '@backstage/test-utils'; jest.mock('../../hooks/useWebsiteForEntity', () => ({ useWebsiteForEntity: jest.fn(), @@ -41,7 +42,7 @@ const websiteListResponse = data as WebsiteListResponse; const entityWebsite = websiteListResponse.items[0]; describe('', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; const mockErrorApi: jest.Mocked = { post: jest.fn(), @@ -49,10 +50,10 @@ describe('', () => { }; beforeEach(() => { - apis = ApiRegistry.from([ + apis = TestApiRegistry.from( [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], [errorApiRef, mockErrorApi], - ]); + ); (useWebsiteForEntity as jest.Mock).mockReturnValue({ value: entityWebsite, diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx index 4c92997755..d8a54928b2 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx @@ -16,7 +16,11 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { wrapInTestApp, setupRequestMockHandlers } from '@backstage/test-utils'; +import { + wrapInTestApp, + setupRequestMockHandlers, + TestApiRegistry, +} from '@backstage/test-utils'; import AuditListTable from './AuditListTable'; import { @@ -28,19 +32,20 @@ import { formatTime } from '../../utils'; import { setupServer } from 'msw/node'; import * as data from '../../__fixtures__/website-list-response.json'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const websiteListResponse = data as WebsiteListResponse; describe('AuditListTable', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; const server = setupServer(); setupRequestMockHandlers(server); beforeEach(() => { - apis = ApiRegistry.from([ - [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], + apis = TestApiRegistry.from([ + lighthouseApiRef, + new LighthouseRestApi('http://lighthouse'), ]); }); diff --git a/plugins/lighthouse/src/components/AuditList/index.test.tsx b/plugins/lighthouse/src/components/AuditList/index.test.tsx index ea3587fea9..2b422cce7a 100644 --- a/plugins/lighthouse/src/components/AuditList/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.test.tsx @@ -23,7 +23,11 @@ jest.mock('react-router-dom', () => { }; }); -import { setupRequestMockHandlers, wrapInTestApp } from '@backstage/test-utils'; +import { + setupRequestMockHandlers, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { fireEvent, render } from '@testing-library/react'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -35,20 +39,21 @@ import { } from '../../api'; import * as data from '../../__fixtures__/website-list-response.json'; import AuditList from './index'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const { useNavigate } = jest.requireMock('react-router-dom'); const websiteListResponse = data as WebsiteListResponse; describe('AuditList', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; const server = setupServer(); setupRequestMockHandlers(server); beforeEach(() => { - apis = ApiRegistry.from([ - [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], + apis = TestApiRegistry.from([ + lighthouseApiRef, + new LighthouseRestApi('http://lighthouse'), ]); }); diff --git a/plugins/lighthouse/src/components/AuditView/index.test.tsx b/plugins/lighthouse/src/components/AuditView/index.test.tsx index ed1bbbcaf9..00b2843a50 100644 --- a/plugins/lighthouse/src/components/AuditView/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.test.tsx @@ -26,7 +26,11 @@ jest.mock('react-router-dom', () => { }; }); -import { setupRequestMockHandlers, wrapInTestApp } from '@backstage/test-utils'; +import { + setupRequestMockHandlers, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { render } from '@testing-library/react'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -35,14 +39,14 @@ import { Audit, lighthouseApiRef, LighthouseRestApi, Website } from '../../api'; import { formatTime } from '../../utils'; import * as data from '../../__fixtures__/website-response.json'; import AuditView from './index'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const { useParams }: { useParams: jest.Mock } = jest.requireMock('react-router-dom'); const websiteResponse = data as Website; describe('AuditView', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; let id: string; const server = setupServer(); @@ -55,8 +59,9 @@ describe('AuditView', () => { ), ); - apis = ApiRegistry.from([ - [lighthouseApiRef, new LighthouseRestApi('https://lighthouse')], + apis = TestApiRegistry.from([ + lighthouseApiRef, + new LighthouseRestApi('https://lighthouse'), ]); id = websiteResponse.audits.find(a => a.status === 'COMPLETED') ?.id as string; diff --git a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx index 84493e243f..59847b516e 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx @@ -23,7 +23,11 @@ jest.mock('react-router-dom', () => { }; }); -import { setupRequestMockHandlers, wrapInTestApp } from '@backstage/test-utils'; +import { + setupRequestMockHandlers, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { fireEvent, render, waitFor } from '@testing-library/react'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -32,7 +36,7 @@ import { Audit, lighthouseApiRef, LighthouseRestApi } from '../../api'; import * as data from '../../__fixtures__/create-audit-response.json'; import CreateAudit from './index'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api'; const { useNavigate }: { useNavigate: jest.Mock } = @@ -41,17 +45,17 @@ const createAuditResponse = data as Audit; // TODO add act() to these tests without breaking them! describe('CreateAudit', () => { - let apis: ApiRegistry; + let apis: TestApiRegistry; let errorApi: ErrorApi; const server = setupServer(); setupRequestMockHandlers(server); beforeEach(() => { errorApi = { post: jest.fn(), error$: jest.fn() }; - apis = ApiRegistry.from([ + apis = TestApiRegistry.from( [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], [errorApiRef, errorApi], - ]); + ); }); it('renders the form', () => { diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx index 64f7483081..13da0f6f4f 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx @@ -21,8 +21,8 @@ import { lighthouseApiRef, WebsiteListResponse } from '../api'; import * as data from '../__fixtures__/website-list-response.json'; import { useWebsiteForEntity } from './useWebsiteForEntity'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; const websiteListResponse = data as WebsiteListResponse; const website = websiteListResponse.items[0]; @@ -55,14 +55,14 @@ describe('useWebsiteForEntity', () => { const wrapper = ({ children }: PropsWithChildren<{}>) => { return ( - {children} - + ); }; diff --git a/plugins/lighthouse/src/plugin.ts b/plugins/lighthouse/src/plugin.ts index 25cbeb3f31..1c9bc295f8 100644 --- a/plugins/lighthouse/src/plugin.ts +++ b/plugins/lighthouse/src/plugin.ts @@ -25,22 +25,19 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '', - title: 'Lighthouse', + id: 'lighthouse', }); export const viewAuditRouteRef = createRouteRef({ - path: 'audit/:id', - title: 'View Lighthouse Audit', + id: 'lighthouse:audit', }); export const createAuditRouteRef = createRouteRef({ - path: 'create-audit', - title: 'Create Lighthouse Audit', + id: 'lighthouse:create-audit', }); export const entityContentRouteRef = createRouteRef({ - title: 'Lighthouse Entity Content', + id: 'lighthouse:entity-content', }); export const lighthousePlugin = createPlugin({ diff --git a/plugins/newrelic/CHANGELOG.md b/plugins/newrelic/CHANGELOG.md index 7ffd4b06d1..89b77e9866 100644 --- a/plugins/newrelic/CHANGELOG.md +++ b/plugins/newrelic/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-newrelic +## 0.3.9 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.8 ### Patch Changes diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 271daa566e..6676547a85 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-newrelic", "description": "A Backstage plugin that integrates towards New Relic", - "version": "0.3.8", + "version": "0.3.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -43,10 +43,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/newrelic/src/plugin.ts b/plugins/newrelic/src/plugin.ts index cb2505e36e..6cf77a785f 100644 --- a/plugins/newrelic/src/plugin.ts +++ b/plugins/newrelic/src/plugin.ts @@ -24,8 +24,7 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/newrelic', - title: 'newrelic', + id: 'newrelic', }); export const newRelicPlugin = createPlugin({ diff --git a/plugins/org/CHANGELOG.md b/plugins/org/CHANGELOG.md index 6e1cc7064f..37c3320810 100644 --- a/plugins/org/CHANGELOG.md +++ b/plugins/org/CHANGELOG.md @@ -1,5 +1,24 @@ # @backstage/plugin-org +## 0.3.29 + +### Patch Changes + +- 2f4a686411: Use email links in the catalog's members list instead of text to display a member's email +- Updated dependencies + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.3.28 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.27 ### Patch Changes diff --git a/plugins/org/package.json b/plugins/org/package.json index 5348f67523..2adb6c5d18 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-org", "description": "A Backstage plugin that helps you create entity pages for your organization", - "version": "0.3.27", + "version": "0.3.29", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,11 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -37,10 +37,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx index 0a5a0cfef1..b6b549d934 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx @@ -15,8 +15,8 @@ */ import { Entity, GroupEntity } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { catalogApiRef, EntityProvider } from '@backstage/plugin-catalog-react'; +import { TestApiProvider } from '@backstage/test-utils'; import { Grid } from '@material-ui/core'; import React from 'react'; import { MemoryRouter } from 'react-router'; @@ -99,12 +99,9 @@ const catalogApi = (items: Entity[]) => ({ getEntities: () => Promise.resolve({ items }), }); -const apiRegistry = (items: Entity[]) => - ApiRegistry.from([[catalogApiRef, catalogApi(items)]]); - export const Default = () => ( - + @@ -112,13 +109,13 @@ export const Default = () => ( - + ); export const Empty = () => ( - + @@ -126,6 +123,6 @@ export const Empty = () => ( - + ); diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx index 75cb9efd7f..b0c68d9a83 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx @@ -20,10 +20,13 @@ import { catalogApiRef, EntityProvider, } from '@backstage/plugin-catalog-react'; -import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { + renderWithEffects, + TestApiProvider, + wrapInTestApp, +} from '@backstage/test-utils'; import React from 'react'; import { MembersListCard } from './MembersListCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('MemberTab Test', () => { const groupEntity: GroupEntity = { @@ -103,17 +106,15 @@ describe('MemberTab Test', () => { }), }; - const apis = ApiRegistry.from([[catalogApiRef, catalogApi]]); - it('Display Profile Card', async () => { const rendered = await renderWithEffects( wrapInTestApp( - + , - , + , ), ); diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx index 0642e80a7b..7a15bd2122 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx @@ -28,14 +28,13 @@ import { Box, createStyles, Grid, - Link, makeStyles, Theme, Typography, } from '@material-ui/core'; import Pagination from '@material-ui/lab/Pagination'; import React from 'react'; -import { generatePath, Link as RouterLink } from 'react-router-dom'; +import { generatePath } from 'react-router-dom'; import { useAsync } from 'react-use'; import { @@ -43,6 +42,7 @@ import { InfoCard, Progress, ResponseErrorPanel, + Link, } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; @@ -90,7 +90,6 @@ const MemberComponent = ({ member }: { member: UserEntity }) => { { {displayName} - {profile?.email} + {profile?.email && ( + {profile.email} + )} diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.stories.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.stories.tsx index 14fed985b6..6a20035dfa 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.stories.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.stories.tsx @@ -15,14 +15,14 @@ */ import { GroupEntity } from '@backstage/catalog-model'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { CatalogApi, catalogApiRef, catalogRouteRef, EntityProvider, } from '@backstage/plugin-catalog-react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { BackstageTheme, createTheme, @@ -86,11 +86,11 @@ const catalogApi: Partial = { getEntities: () => Promise.resolve({ items: [serviceA, serviceB, websiteA] }), }; -const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]); +const apis = TestApiRegistry.from([catalogApiRef, catalogApi]); export const Default = () => wrapInTestApp( - + @@ -123,7 +123,7 @@ const monochromeTheme = (outer: BackstageTheme) => export const Themed = () => wrapInTestApp( - + diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx index b5ffd37083..8d6fc1a002 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx @@ -21,11 +21,10 @@ import { EntityProvider, catalogRouteRef, } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { queryByText } from '@testing-library/react'; import React from 'react'; import { OwnershipCard } from './OwnershipCard'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('OwnershipCard', () => { const groupEntity: GroupEntity = { @@ -121,11 +120,11 @@ describe('OwnershipCard', () => { }); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/create': catalogRouteRef, @@ -157,11 +156,11 @@ describe('OwnershipCard', () => { }); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/create': catalogRouteRef, @@ -205,11 +204,11 @@ describe('OwnershipCard', () => { }); const { getByText } = await renderInTestApp( - + - , + , { mountedRoutes: { '/create': catalogRouteRef, diff --git a/plugins/pagerduty/CHANGELOG.md b/plugins/pagerduty/CHANGELOG.md index 11ae548db8..bb74598f2a 100644 --- a/plugins/pagerduty/CHANGELOG.md +++ b/plugins/pagerduty/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-pagerduty +## 0.3.18 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.17 ### Patch Changes diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index 7b42a15bc6..13cb8854ae 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-pagerduty", "description": "A Backstage plugin that integrates towards PagerDuty", - "version": "0.3.17", + "version": "0.3.18", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,11 +31,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -48,10 +48,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.test.tsx b/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.test.tsx index 446c867f35..723e7b7b19 100644 --- a/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.test.tsx +++ b/plugins/pagerduty/src/components/ChangeEvents/ChangeEvents.test.tsx @@ -16,15 +16,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { ChangeEvent } from '../types'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { pagerDutyApiRef } from '../../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { ChangeEvents } from './ChangeEvents'; const mockPagerDutyApi = { - getChangeEventsByServiceId: () => [], + getChangeEventsByServiceId: jest.fn(), }; -const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]); +const apis = TestApiRegistry.from([pagerDutyApiRef, mockPagerDutyApi]); describe('Incidents', () => { it('Renders an empty state when there are no change events', async () => { diff --git a/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx b/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx index 52aa9a860f..47f18002fe 100644 --- a/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx +++ b/plugins/pagerduty/src/components/Escalation/Escalation.test.tsx @@ -16,15 +16,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { EscalationPolicy } from './EscalationPolicy'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { User } from '../types'; import { pagerDutyApiRef } from '../../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const mockPagerDutyApi = { - getOnCallByPolicyId: () => [], + getOnCallByPolicyId: jest.fn(), }; -const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]); +const apis = TestApiRegistry.from([pagerDutyApiRef, mockPagerDutyApi]); describe('Escalation', () => { it('Handles an empty response', async () => { diff --git a/plugins/pagerduty/src/components/Incident/Incidents.test.tsx b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx index d66acc0879..74e7d82e8b 100644 --- a/plugins/pagerduty/src/components/Incident/Incidents.test.tsx +++ b/plugins/pagerduty/src/components/Incident/Incidents.test.tsx @@ -16,15 +16,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { Incidents } from './Incidents'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { pagerDutyApiRef } from '../../api'; import { Incident } from '../types'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const mockPagerDutyApi = { - getIncidentsByServiceId: () => [], + getIncidentsByServiceId: jest.fn(), }; -const apis = ApiRegistry.from([[pagerDutyApiRef, mockPagerDutyApi]]); +const apis = TestApiRegistry.from([pagerDutyApiRef, mockPagerDutyApi]); describe('Incidents', () => { it('Renders an empty state when there are no incidents', async () => { diff --git a/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx b/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx index d644aa019a..e0a857ccc7 100644 --- a/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx +++ b/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx @@ -18,12 +18,12 @@ import { render, waitFor, fireEvent, act } from '@testing-library/react'; import { PagerDutyCard } from '../PagerDutyCard'; import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { pagerDutyApiRef, UnauthorizedError, PagerDutyClient } from '../../api'; import { Service, User } from '../types'; -import { alertApiRef, createApiRef } from '@backstage/core-plugin-api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { ApiProvider } from '@backstage/core-app-api'; const mockPagerDutyApi: Partial = { getServiceByIntegrationKey: async () => [], @@ -31,16 +31,10 @@ const mockPagerDutyApi: Partial = { getIncidentsByServiceId: async () => [], }; -const apis = ApiRegistry.from([ +const apis = TestApiRegistry.from( [pagerDutyApiRef, mockPagerDutyApi], - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], -]); + [alertApiRef, {}], +); const entity: Entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', diff --git a/plugins/pagerduty/src/components/TriggerButton/index.test.tsx b/plugins/pagerduty/src/components/TriggerButton/index.test.tsx index 21f0dd5f1d..3776bcaf1c 100644 --- a/plugins/pagerduty/src/components/TriggerButton/index.test.tsx +++ b/plugins/pagerduty/src/components/TriggerButton/index.test.tsx @@ -15,16 +15,15 @@ */ import React from 'react'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { pagerDutyApiRef } from '../../api'; import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { TriggerButton } from './'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { alertApiRef, - createApiRef, IdentityApi, identityApiRef, } from '@backstage/core-plugin-api'; @@ -39,17 +38,11 @@ describe('TriggerButton', () => { triggerAlarm: mockTriggerAlarmFn, }; - const apis = ApiRegistry.from([ - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], + const apis = TestApiRegistry.from( + [alertApiRef, {}], [identityApiRef, mockIdentityApi], [pagerDutyApiRef, mockPagerDutyApi], - ]); + ); it('renders the trigger button, opens and closes dialog', async () => { const entity: Entity = { diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx index 444e820b98..d6f378b4bf 100644 --- a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx @@ -15,16 +15,15 @@ */ import React from 'react'; import { fireEvent, act } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { pagerDutyApiRef } from '../../api'; import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { TriggerDialog } from './TriggerDialog'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { alertApiRef, - createApiRef, IdentityApi, identityApiRef, } from '@backstage/core-plugin-api'; @@ -39,17 +38,11 @@ describe('TriggerDialog', () => { triggerAlarm: mockTriggerAlarmFn, }; - const apis = ApiRegistry.from([ - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], + const apis = TestApiRegistry.from( + [alertApiRef, {}], [identityApiRef, mockIdentityApi], [pagerDutyApiRef, mockPagerDutyApi], - ]); + ); it('open the dialog and trigger an alarm', async () => { const entity: Entity = { diff --git a/plugins/pagerduty/src/plugin.ts b/plugins/pagerduty/src/plugin.ts index fb1e573f0c..d1c4e6d35b 100644 --- a/plugins/pagerduty/src/plugin.ts +++ b/plugins/pagerduty/src/plugin.ts @@ -24,8 +24,7 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/pagerduty', - title: 'pagerduty', + id: 'pagerduty', }); export const pagerDutyPlugin = createPlugin({ diff --git a/plugins/permission-backend/.eslintrc.js b/plugins/permission-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/permission-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/permission-backend/CHANGELOG.md b/plugins/permission-backend/CHANGELOG.md new file mode 100644 index 0000000000..28df6bccbc --- /dev/null +++ b/plugins/permission-backend/CHANGELOG.md @@ -0,0 +1,29 @@ +# @backstage/plugin-permission-backend + +## 0.2.0 + +### Minor Changes + +- 450ca92330: Change route used for integration between the authorization framework and other plugin backends to use the /.well-known prefix. + +### Patch Changes + +- e7851efa9e: Rename and adjust permission policy return type to reduce nesting +- Updated dependencies + - @backstage/plugin-auth-backend@0.4.10 + - @backstage/plugin-permission-node@0.2.0 + - @backstage/backend-common@0.9.12 + +## 0.1.0 + +### Minor Changes + +- 7a8312f126: New package containing the backend for authorization and permissions. For more information, see the [authorization PRFC](https://github.com/backstage/backstage/pull/7761). + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-backend@0.4.9 + - @backstage/plugin-permission-node@0.1.0 + - @backstage/backend-common@0.9.11 + - @backstage/plugin-permission-common@0.2.0 diff --git a/plugins/permission-backend/README.md b/plugins/permission-backend/README.md new file mode 100644 index 0000000000..59fe1730ba --- /dev/null +++ b/plugins/permission-backend/README.md @@ -0,0 +1,6 @@ +# @backstage/plugin-permission-backend + +> NOTE: THIS PACKAGE IS EXPERIMENTAL, HERE BE DRAGONS + +Backend for Backstage authorization and permissions. For more information, see +the [authorization PRFC](https://github.com/backstage/backstage/pull/7761). diff --git a/plugins/permission-backend/api-report.md b/plugins/permission-backend/api-report.md new file mode 100644 index 0000000000..4857c8b044 --- /dev/null +++ b/plugins/permission-backend/api-report.md @@ -0,0 +1,26 @@ +## API Report File for "@backstage/plugin-permission-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import express from 'express'; +import { IdentityClient } from '@backstage/plugin-auth-backend'; +import { Logger as Logger_2 } from 'winston'; +import { PermissionPolicy } from '@backstage/plugin-permission-node'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +// @public +export function createRouter(options: RouterOptions): Promise; + +// @public +export interface RouterOptions { + // (undocumented) + discovery: PluginEndpointDiscovery; + // (undocumented) + identity: IdentityClient; + // (undocumented) + logger: Logger_2; + // (undocumented) + policy: PermissionPolicy; +} +``` diff --git a/plugins/permission-backend/package.json b/plugins/permission-backend/package.json new file mode 100644 index 0000000000..2018439398 --- /dev/null +++ b/plugins/permission-backend/package.json @@ -0,0 +1,44 @@ +{ + "name": "@backstage/plugin-permission-backend", + "version": "0.2.0", + "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.9.12", + "@backstage/config": "^0.1.11", + "@backstage/plugin-auth-backend": "^0.4.10", + "@backstage/plugin-permission-common": "^0.2.0", + "@backstage/plugin-permission-node": "^0.2.0", + "@types/express": "*", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "node-fetch": "^2.6.1", + "winston": "^3.2.1", + "yn": "^4.0.0", + "zod": "^3.11.6" + }, + "devDependencies": { + "@backstage/cli": "^0.10.0", + "@types/supertest": "^2.0.8", + "supertest": "^4.0.2", + "msw": "^0.35.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/permission-backend/src/index.ts b/plugins/permission-backend/src/index.ts new file mode 100644 index 0000000000..877b9070c3 --- /dev/null +++ b/plugins/permission-backend/src/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Backend for Backstage authorization and permissions. + * @packageDocumentation + */ +export * from './service'; diff --git a/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts b/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts new file mode 100644 index 0000000000..7f46490ce2 --- /dev/null +++ b/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts @@ -0,0 +1,292 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AddressInfo } from 'net'; +import { Server } from 'http'; +import express, { Router } from 'express'; +import { RestContext, rest } from 'msw'; +import { setupServer, SetupServerApi } from 'msw/node'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; +import { PermissionIntegrationClient } from './PermissionIntegrationClient'; + +describe('PermissionIntegrationClient', () => { + describe('applyConditions', () => { + let server: SetupServerApi; + + const mockConditions = { + not: { + allOf: [ + { rule: 'RULE_1', params: [] }, + { rule: 'RULE_2', params: ['abc'] }, + ], + }, + }; + + const mockApplyConditionsHandler = jest.fn( + (_req, res, { json }: RestContext) => { + return res(json({ result: AuthorizeResult.ALLOW })); + }, + ); + + const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; + const discovery: PluginEndpointDiscovery = { + async getBaseUrl() { + return mockBaseUrl; + }, + async getExternalBaseUrl() { + throw new Error('Not implemented.'); + }, + }; + + const client: PermissionIntegrationClient = new PermissionIntegrationClient( + { + discovery, + }, + ); + + beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: 'error' }); + server.use( + rest.post( + `${mockBaseUrl}/.well-known/backstage/permissions/apply-conditions`, + mockApplyConditionsHandler, + ), + ); + }); + + afterAll(() => server.close()); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should make a POST request to the correct endpoint', async () => { + await client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }); + + expect(mockApplyConditionsHandler).toHaveBeenCalled(); + }); + + it('should include a request body', async () => { + await client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }); + + expect(mockApplyConditionsHandler).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }, + }), + expect.anything(), + expect.anything(), + ); + }); + + it('should return the response from the fetch request', async () => { + const response = await client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }); + + expect(response).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + }); + + it('should not include authorization headers if no token is supplied', async () => { + await client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }); + + const request = mockApplyConditionsHandler.mock.calls[0][0]; + expect(request.headers.has('authorization')).toEqual(false); + }); + + it('should include correctly-constructed authorization header if token is supplied', async () => { + await client.applyConditions( + { + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }, + 'Bearer fake-token', + ); + + const request = mockApplyConditionsHandler.mock.calls[0][0]; + expect(request.headers.get('authorization')).toEqual('Bearer fake-token'); + }); + + it('should forward response errors', async () => { + mockApplyConditionsHandler.mockImplementationOnce( + (_req, res, { status }: RestContext) => { + return res(status(401)); + }, + ); + + await expect( + client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }), + ).rejects.toThrowError(/401/i); + }); + + it('should reject invalid responses', async () => { + mockApplyConditionsHandler.mockImplementationOnce( + (_req, res, { json }: RestContext) => { + return res(json({ outcome: AuthorizeResult.ALLOW })); + }, + ); + + await expect( + client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }), + ).rejects.toThrowError(/invalid input/i); + }); + }); + + describe('integration with @backstage/plugin-permission-node', () => { + let server: Server; + let client: PermissionIntegrationClient; + + beforeAll(async () => { + const router = Router(); + + router.use( + createPermissionIntegrationRouter({ + resourceType: 'test-resource', + getResource: async resourceRef => ({ id: resourceRef }), + rules: [ + { + name: 'RULE_1', + description: 'Test rule 1', + apply: (_resource: any, input: 'yes' | 'no') => input === 'yes', + toQuery: () => { + throw new Error('Not implemented'); + }, + }, + { + name: 'RULE_2', + description: 'Test rule 2', + apply: (_resource: any, input: 'yes' | 'no') => input === 'yes', + toQuery: () => { + throw new Error('Not implemented'); + }, + }, + ], + }), + ); + + const app = express(); + + app.use('/test-plugin', router); + + await new Promise(resolve => { + server = app.listen(resolve); + }); + + const discovery: PluginEndpointDiscovery = { + async getBaseUrl(pluginId: string) { + const listenPort = (server.address()! as AddressInfo).port; + + return `http://0.0.0.0:${listenPort}/${pluginId}`; + }, + async getExternalBaseUrl() { + throw new Error('Not implemented.'); + }, + }; + + client = new PermissionIntegrationClient({ + discovery, + }); + }); + + afterAll( + async () => + new Promise((resolve, reject) => + server.close(err => (err ? reject(err) : resolve())), + ), + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('works for simple conditions', async () => { + await expect( + client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: { rule: 'RULE_1', params: ['no'] }, + }), + ).resolves.toEqual({ result: AuthorizeResult.DENY }); + }); + + it('works for complex criteria', async () => { + await expect( + client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: { + allOf: [ + { + allOf: [ + { rule: 'RULE_1', params: ['yes'] }, + { not: { rule: 'RULE_2', params: ['no'] } }, + ], + }, + { + not: { + allOf: [ + { rule: 'RULE_1', params: ['no'] }, + { rule: 'RULE_2', params: ['yes'] }, + ], + }, + }, + ], + }, + }), + ).resolves.toEqual({ result: AuthorizeResult.ALLOW }); + }); + }); +}); diff --git a/plugins/permission-backend/src/service/PermissionIntegrationClient.ts b/plugins/permission-backend/src/service/PermissionIntegrationClient.ts new file mode 100644 index 0000000000..de42a7f194 --- /dev/null +++ b/plugins/permission-backend/src/service/PermissionIntegrationClient.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fetch from 'node-fetch'; +import { z } from 'zod'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { + AuthorizeResult, + PermissionCondition, + PermissionCriteria, +} from '@backstage/plugin-permission-common'; +import { + ApplyConditionsRequest, + ApplyConditionsResponse, +} from '@backstage/plugin-permission-node'; + +const responseSchema = z.object({ + result: z.literal(AuthorizeResult.ALLOW).or(z.literal(AuthorizeResult.DENY)), +}); + +export class PermissionIntegrationClient { + private readonly discovery: PluginEndpointDiscovery; + + constructor(options: { discovery: PluginEndpointDiscovery }) { + this.discovery = options.discovery; + } + + async applyConditions( + { + pluginId, + resourceRef, + resourceType, + conditions, + }: { + resourceRef: string; + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; + }, + authHeader?: string, + ): Promise { + const endpoint = `${await this.discovery.getBaseUrl( + pluginId, + )}/.well-known/backstage/permissions/apply-conditions`; + + const request: ApplyConditionsRequest = { + resourceRef, + resourceType, + conditions, + }; + + const response = await fetch(endpoint, { + method: 'POST', + body: JSON.stringify(request), + headers: { + ...(authHeader ? { authorization: authHeader } : {}), + 'content-type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `Unexpected response from plugin upstream when applying conditions. Expected 200 but got ${response.status} - ${response.statusText}`, + ); + } + + return responseSchema.parse(await response.json()); + } +} diff --git a/plugins/permission-backend/src/service/index.ts b/plugins/permission-backend/src/service/index.ts new file mode 100644 index 0000000000..3ce8cdbcc4 --- /dev/null +++ b/plugins/permission-backend/src/service/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { RouterOptions } from './router'; diff --git a/plugins/permission-backend/src/service/router.test.ts b/plugins/permission-backend/src/service/router.test.ts new file mode 100644 index 0000000000..26fa7ef8a6 --- /dev/null +++ b/plugins/permission-backend/src/service/router.test.ts @@ -0,0 +1,294 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { getVoidLogger } from '@backstage/backend-common'; +import { IdentityClient } from '@backstage/plugin-auth-backend'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { ApplyConditionsResponse } from '@backstage/plugin-permission-node'; +import { PermissionIntegrationClient } from './PermissionIntegrationClient'; + +import { createRouter } from './router'; + +const mockApplyConditions: jest.MockedFunction< + InstanceType['applyConditions'] +> = jest.fn(); +jest.mock('./PermissionIntegrationClient', () => ({ + PermissionIntegrationClient: jest.fn(() => ({ + applyConditions: mockApplyConditions, + })), +})); + +const policy = { + handle: jest.fn().mockImplementation((_req, identity) => { + if (identity) { + return { result: AuthorizeResult.ALLOW }; + } + return { result: AuthorizeResult.DENY }; + }), +}; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + discovery: { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), + }, + identity: { + authenticate: jest.fn(token => { + if (!token) { + throw new Error('No token supplied!'); + } + + return Promise.resolve({ + id: 'test-user', + token, + }); + }), + } as unknown as IdentityClient, + policy, + }); + + app = express().use(router); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); + + describe('POST /authorize', () => { + it('calls the permission policy', async () => { + const response = await request(app) + .post('/authorize') + .send([ + { + id: '123', + permission: { + name: 'test.permission1', + attributes: {}, + }, + }, + { + id: '234', + permission: { + name: 'test.permission2', + attributes: {}, + }, + }, + ]); + + expect(response.status).toEqual(200); + + expect(policy.handle).toHaveBeenCalledWith( + { + permission: { + name: 'test.permission1', + attributes: {}, + }, + }, + undefined, + ); + expect(policy.handle).toHaveBeenCalledWith( + { + permission: { + name: 'test.permission2', + attributes: {}, + }, + }, + undefined, + ); + + expect(response.body).toEqual([ + { id: '123', result: AuthorizeResult.DENY }, + { id: '234', result: AuthorizeResult.DENY }, + ]); + }); + + it('resolves identity from the Authorization header', async () => { + const token = 'test-token'; + const response = await request(app) + .post('/authorize') + .auth(token, { type: 'bearer' }) + .send([ + { + id: '123', + permission: { + name: 'test.permission', + attributes: {}, + }, + }, + ]); + + expect(response.status).toEqual(200); + expect(policy.handle).toHaveBeenCalledWith( + { + permission: { + name: 'test.permission', + attributes: {}, + }, + }, + { id: 'test-user', token: 'test-token' }, + ); + expect(response.body).toEqual([ + { id: '123', result: AuthorizeResult.ALLOW }, + ]); + }); + + describe('conditional policy result', () => { + beforeEach(() => { + policy.handle.mockReturnValueOnce({ + result: AuthorizeResult.CONDITIONAL, + pluginId: 'test-plugin', + resourceType: 'test-resource-1', + conditions: { + anyOf: [{ rule: 'test-rule', params: ['abc'] }], + }, + }); + }); + + it('returns conditions if no resourceRef is supplied', async () => { + const response = await request(app) + .post('/authorize') + .send([ + { + id: '123', + permission: { + name: 'test.permission', + resourceType: 'test-resource-1', + attributes: {}, + }, + }, + ]); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + id: '123', + result: AuthorizeResult.CONDITIONAL, + conditions: { anyOf: [{ rule: 'test-rule', params: ['abc'] }] }, + }, + ]); + }); + + it.each([ + AuthorizeResult.ALLOW, + AuthorizeResult.DENY, + ])( + 'applies conditions and returns %s if resourceRef is supplied', + async result => { + mockApplyConditions.mockResolvedValueOnce({ + result, + }); + + const response = await request(app) + .post('/authorize') + .auth('test-token', { type: 'bearer' }) + .send([ + { + id: '123', + resourceRef: 'test/resource', + permission: { + name: 'test.permission', + resourceType: 'test-resource-1', + attributes: {}, + }, + }, + ]); + + expect(mockApplyConditions).toHaveBeenCalledWith( + { + pluginId: 'test-plugin', + resourceType: 'test-resource-1', + resourceRef: 'test/resource', + conditions: { anyOf: [{ rule: 'test-rule', params: ['abc'] }] }, + }, + 'Bearer test-token', + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + id: '123', + result, + }, + ]); + }, + ); + }); + + it.each([ + undefined, + '', + {}, + [{ permission: { name: 'test.permission', attributes: {} } }], + [{ id: '123' }], + [{ id: '123', permission: { name: 'test.permission' } }], + [{ id: '123', permission: { attributes: { invalid: 'attribute' } } }], + ])('returns a 500 error for invalid request %#', async requestBody => { + const response = await request(app).post('/authorize').send(requestBody); + + expect(response.status).toEqual(500); + expect(response.body).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringMatching(/invalid/i), + }), + }), + ); + }); + + it('returns a 500 error if the policy returns a different resourceType', async () => { + policy.handle.mockReturnValueOnce({ + result: AuthorizeResult.CONDITIONAL, + pluginId: 'test-plugin', + resourceType: 'test-resource-2', + conditions: {}, + }); + + const response = await request(app) + .post('/authorize') + .send([ + { + id: '123', + permission: { + name: 'test.permission', + resourceType: 'test-resource-1', + attributes: {}, + }, + }, + ]); + + expect(response.status).toEqual(500); + expect(response.body).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringMatching(/invalid resource conditions/i), + }), + }), + ); + }); + }); +}); diff --git a/plugins/permission-backend/src/service/router.ts b/plugins/permission-backend/src/service/router.ts new file mode 100644 index 0000000000..b85e7feb85 --- /dev/null +++ b/plugins/permission-backend/src/service/router.ts @@ -0,0 +1,165 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import express, { Request, Response } from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { + errorHandler, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { + BackstageIdentity, + IdentityClient, +} from '@backstage/plugin-auth-backend'; +import { + AuthorizeResult, + AuthorizeResponse, + AuthorizeRequest, + Identified, +} from '@backstage/plugin-permission-common'; +import { PermissionPolicy } from '@backstage/plugin-permission-node'; +import { PermissionIntegrationClient } from './PermissionIntegrationClient'; + +const requestSchema: z.ZodSchema[]> = z.array( + z.object({ + id: z.string(), + resourceRef: z.string().optional(), + permission: z.object({ + name: z.string(), + resourceType: z.string().optional(), + attributes: z.object({ + action: z + .union([ + z.literal('create'), + z.literal('read'), + z.literal('update'), + z.literal('delete'), + ]) + .optional(), + }), + }), + }), +); + +/** + * Options required when constructing a new {@link express#Router} using + * {@link createRouter}. + * + * @public + */ +export interface RouterOptions { + logger: Logger; + discovery: PluginEndpointDiscovery; + policy: PermissionPolicy; + identity: IdentityClient; +} + +const handleRequest = async ( + { id, resourceRef, ...request }: Identified, + user: BackstageIdentity | undefined, + policy: PermissionPolicy, + permissionIntegrationClient: PermissionIntegrationClient, + authHeader?: string, +): Promise> => { + const response = await policy.handle(request, user); + + if (response.result === AuthorizeResult.CONDITIONAL) { + // Sanity check that any resource provided matches the one expected by the permission + if (request.permission.resourceType !== response.resourceType) { + throw new Error( + `Invalid resource conditions returned from permission policy for permission ${request.permission.name}`, + ); + } + + if (resourceRef) { + return { + id, + ...(await permissionIntegrationClient.applyConditions( + { + resourceRef, + pluginId: response.pluginId, + resourceType: response.resourceType, + conditions: response.conditions, + }, + authHeader, + )), + }; + } + + return { + id, + result: AuthorizeResult.CONDITIONAL, + conditions: response.conditions, + }; + } + + return { id, ...response }; +}; + +/** + * Creates a new {@link express#Router} which provides the backend API + * for the permission system. + * + * @public + */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { policy, discovery, identity } = options; + + const permissionIntegrationClient = new PermissionIntegrationClient({ + discovery, + }); + + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, response) => { + response.send({ status: 'ok' }); + }); + + router.post( + '/authorize', + async ( + req: Request[]>, + res: Response[]>, + ) => { + const token = IdentityClient.getBearerToken(req.header('authorization')); + const user = token ? await identity.authenticate(token) : undefined; + + const body = requestSchema.parse(req.body); + + res.json( + await Promise.all( + body.map(request => + handleRequest( + request, + user, + policy, + permissionIntegrationClient, + req.header('authorization'), + ), + ), + ), + ); + }, + ); + + router.use(errorHandler()); + return router; +} diff --git a/packages/test-utils-core/src/index.ts b/plugins/permission-backend/src/setupTests.ts similarity index 81% rename from packages/test-utils-core/src/index.ts rename to plugins/permission-backend/src/setupTests.ts index 8f0a5b31e1..a330613afb 100644 --- a/packages/test-utils-core/src/index.ts +++ b/plugins/permission-backend/src/setupTests.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2021 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,3 @@ */ export {}; -throw new Error( - 'This module has been removed. Use @backstage/dev-utils instead', -); diff --git a/plugins/permission-common/CHANGELOG.md b/plugins/permission-common/CHANGELOG.md new file mode 100644 index 0000000000..c2ee5a3ecf --- /dev/null +++ b/plugins/permission-common/CHANGELOG.md @@ -0,0 +1,12 @@ +# @backstage/plugin-permission-common + +## 0.2.0 + +### Minor Changes + +- 92439056fb: Accept configApi rather than enabled flag in PermissionClient constructor. + +### Patch Changes + +- Updated dependencies + - @backstage/errors@0.1.5 diff --git a/plugins/permission-common/api-report.md b/plugins/permission-common/api-report.md index cbf1877c75..043b27554c 100644 --- a/plugins/permission-common/api-report.md +++ b/plugins/permission-common/api-report.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { Config } from '@backstage/config'; + // @public export type AuthorizeRequest = { permission: Permission; @@ -67,7 +69,7 @@ export type PermissionAttributes = { // @public export class PermissionClient { - constructor(options: { discoveryApi: DiscoveryApi; enabled?: boolean }); + constructor(options: { discoveryApi: DiscoveryApi; configApi: Config }); authorize( requests: AuthorizeRequest[], options?: AuthorizeRequestOptions, diff --git a/plugins/permission-common/package.json b/plugins/permission-common/package.json index 5e79c1ba7b..495c262b03 100644 --- a/plugins/permission-common/package.json +++ b/plugins/permission-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-permission-common", "description": "Isomorphic types and client for Backstage permissions and authorization", - "version": "0.1.0", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "publishConfig": { @@ -38,13 +38,14 @@ "url": "https://github.com/backstage/backstage/issues" }, "dependencies": { - "@backstage/errors": "^0.1.2", + "@backstage/config": "^0.1.11", + "@backstage/errors": "^0.1.5", "cross-fetch": "^3.0.6", "uuid": "^8.0.0", "zod": "^3.11.6" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/jest": "^26.0.7", "msw": "^0.35.0" } diff --git a/plugins/permission-common/src/PermissionClient.test.ts b/plugins/permission-common/src/PermissionClient.test.ts index 06e2ae0561..b2e66986a1 100644 --- a/plugins/permission-common/src/PermissionClient.test.ts +++ b/plugins/permission-common/src/PermissionClient.test.ts @@ -16,6 +16,7 @@ import { RestContext, rest } from 'msw'; import { setupServer } from 'msw/node'; +import { ConfigReader } from '@backstage/config'; import { PermissionClient } from './PermissionClient'; import { AuthorizeRequest, AuthorizeResult, Identified } from './types/api'; import { DiscoveryApi } from './types/discovery'; @@ -32,7 +33,7 @@ const discoveryApi: DiscoveryApi = { }; const client: PermissionClient = new PermissionClient({ discoveryApi, - enabled: true, + configApi: new ConfigReader({ permission: { enabled: true } }), }); const mockPermission: Permission = { @@ -145,7 +146,7 @@ describe('PermissionClient', () => { ).rejects.toThrowError(/invalid input/i); }); - it('should allow all when authorization is not enabled', async () => { + it('should allow all when permission.enabled is false', async () => { mockAuthorizeHandler.mockImplementationOnce( (req, res, { json }: RestContext) => { const responses = req.body.map((a: Identified) => ({ @@ -156,7 +157,32 @@ describe('PermissionClient', () => { return res(json(responses)); }, ); - const disabled = new PermissionClient({ discoveryApi, enabled: false }); + const disabled = new PermissionClient({ + discoveryApi, + configApi: new ConfigReader({ permission: { enabled: false } }), + }); + const response = await disabled.authorize([mockAuthorizeRequest]); + expect(response[0]).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + expect(mockAuthorizeHandler).not.toBeCalled(); + }); + + it('should allow all when permission.enabled is not configured', async () => { + mockAuthorizeHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.map((a: Identified) => ({ + id: a.id, + outcome: AuthorizeResult.DENY, + })); + + return res(json(responses)); + }, + ); + const disabled = new PermissionClient({ + discoveryApi, + configApi: new ConfigReader({}), + }); const response = await disabled.authorize([mockAuthorizeRequest]); expect(response[0]).toEqual( expect.objectContaining({ result: AuthorizeResult.ALLOW }), diff --git a/plugins/permission-common/src/PermissionClient.ts b/plugins/permission-common/src/PermissionClient.ts index 5b17ccd333..f98d40a3c4 100644 --- a/plugins/permission-common/src/PermissionClient.ts +++ b/plugins/permission-common/src/PermissionClient.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Config } from '@backstage/config'; import { ResponseError } from '@backstage/errors'; import fetch from 'cross-fetch'; import * as uuid from 'uuid'; @@ -74,9 +75,10 @@ export class PermissionClient { private readonly enabled: boolean; private readonly discoveryApi: DiscoveryApi; - constructor(options: { discoveryApi: DiscoveryApi; enabled?: boolean }) { + constructor(options: { discoveryApi: DiscoveryApi; configApi: Config }) { this.discoveryApi = options.discoveryApi; - this.enabled = options.enabled ?? false; + this.enabled = + options.configApi.getOptionalBoolean('permission.enabled') ?? false; } /** diff --git a/plugins/permission-node/.eslintrc.js b/plugins/permission-node/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/permission-node/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/permission-node/CHANGELOG.md b/plugins/permission-node/CHANGELOG.md new file mode 100644 index 0000000000..64af0e353b --- /dev/null +++ b/plugins/permission-node/CHANGELOG.md @@ -0,0 +1,25 @@ +# @backstage/plugin-permission-node + +## 0.2.0 + +### Minor Changes + +- e7851efa9e: Rename and adjust permission policy return type to reduce nesting +- 450ca92330: Change route used for integration between the authorization framework and other plugin backends to use the /.well-known prefix. + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-backend@0.4.10 + +## 0.1.0 + +### Minor Changes + +- 44b46644d9: New package containing common permission and authorization utilities for backend plugins. For more information, see the [authorization PRFC](https://github.com/backstage/backstage/pull/7761). + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-backend@0.4.9 + - @backstage/plugin-permission-common@0.2.0 diff --git a/plugins/permission-node/README.md b/plugins/permission-node/README.md new file mode 100644 index 0000000000..82dfe54f9c --- /dev/null +++ b/plugins/permission-node/README.md @@ -0,0 +1,7 @@ +# @backstage/plugin-permission-node + +> NOTE: THIS PACKAGE IS EXPERIMENTAL, HERE BE DRAGONS + +Common permission and authorization utilities for backend plugins. For more +information, see the [authorization +PRFC](https://github.com/backstage/backstage/pull/7761). diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md new file mode 100644 index 0000000000..75c45a9250 --- /dev/null +++ b/plugins/permission-node/api-report.md @@ -0,0 +1,126 @@ +## API Report File for "@backstage/plugin-permission-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AuthorizeRequest } from '@backstage/plugin-permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { BackstageIdentity } from '@backstage/plugin-auth-backend'; +import { PermissionCondition } from '@backstage/plugin-permission-common'; +import { PermissionCriteria } from '@backstage/plugin-permission-common'; +import { Router } from 'express'; + +// @public +export type ApplyConditionsRequest = { + resourceRef: string; + resourceType: string; + conditions: PermissionCriteria; +}; + +// @public +export type ApplyConditionsResponse = { + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; +}; + +// @public +export type Condition = TRule extends PermissionRule< + any, + any, + infer TParams +> + ? (...params: TParams) => PermissionCondition + : never; + +// @public +export type ConditionalPolicyDecision = { + result: AuthorizeResult.CONDITIONAL; + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; +}; + +// @public +export type Conditions< + TRules extends Record>, +> = { + [Name in keyof TRules]: Condition; +}; + +// @public +export type ConditionTransformer = ( + conditions: PermissionCriteria, +) => PermissionCriteria; + +// @public +export const createConditionExports: < + TResource, + TRules extends Record>, +>(options: { + pluginId: string; + resourceType: string; + rules: TRules; +}) => { + conditions: Conditions; + createPolicyDecision: ( + conditions: PermissionCriteria, + ) => ConditionalPolicyDecision; +}; + +// @public +export const createConditionFactory: ( + rule: PermissionRule, +) => (...params: TParams) => { + rule: string; + params: TParams; +}; + +// @public +export const createConditionTransformer: < + TQuery, + TRules extends PermissionRule[], +>( + permissionRules: [...TRules], +) => ConditionTransformer; + +// @public +export const createPermissionIntegrationRouter: ({ + resourceType, + rules, + getResource, +}: { + resourceType: string; + rules: PermissionRule[]; + getResource: (resourceRef: string) => Promise; +}) => Router; + +// @public +export interface PermissionPolicy { + // (undocumented) + handle( + request: PolicyAuthorizeRequest, + user?: BackstageIdentity, + ): Promise; +} + +// @public +export type PermissionRule< + TResource, + TQuery, + TParams extends unknown[] = unknown[], +> = { + name: string; + description: string; + apply(resource: TResource, ...params: TParams): boolean; + toQuery(...params: TParams): PermissionCriteria; +}; + +// @public +export type PolicyAuthorizeRequest = Omit; + +// @public +export type PolicyDecision = + | { + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; + } + | ConditionalPolicyDecision; +``` diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json new file mode 100644 index 0000000000..1a3d3688d1 --- /dev/null +++ b/plugins/permission-node/package.json @@ -0,0 +1,46 @@ +{ + "name": "@backstage/plugin-permission-node", + "description": "Common permission and authorization utilities for backend plugins", + "version": "0.2.0", + "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/permission-node" + }, + "keywords": [ + "backstage", + "permissions" + ], + "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/plugin-auth-backend": "^0.4.10", + "@backstage/plugin-permission-common": "^0.2.0", + "@types/express": "^4.17.6", + "express": "^4.17.1", + "zod": "^3.11.6" + }, + "devDependencies": { + "@backstage/cli": "^0.10.0", + "@types/supertest": "^2.0.8", + "supertest": "^6.1.3" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/permission-node/src/index.ts b/plugins/permission-node/src/index.ts new file mode 100644 index 0000000000..39527bac71 --- /dev/null +++ b/plugins/permission-node/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Common permission and authorization utilities for backend plugins + * + * @packageDocumentation + */ +export * from './integration'; +export * from './policy'; +export * from './types'; diff --git a/plugins/permission-node/src/integration/createConditionExports.test.ts b/plugins/permission-node/src/integration/createConditionExports.test.ts new file mode 100644 index 0000000000..f3585d11ad --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionExports.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { createConditionExports } from './createConditionExports'; + +const testIntegration = () => + createConditionExports({ + pluginId: 'test-plugin', + resourceType: 'test-resource', + rules: { + testRule1: { + name: 'testRule1', + description: 'Test rule 1', + apply: jest.fn( + (_resource: any, _firstParam: string, _secondParam: number) => true, + ), + toQuery: jest.fn((firstParam: string, secondParam: number) => ({ + query: 'testRule1', + params: [firstParam, secondParam], + })), + }, + testRule2: { + name: 'testRule2', + description: 'Test rule 2', + apply: jest.fn((_firstParam: object) => false), + toQuery: jest.fn((firstParam: object) => ({ + query: 'testRule2', + params: [firstParam], + })), + }, + }, + }); + +describe('createConditionExports', () => { + describe('conditions', () => { + it('creates condition factories for the supplied rules', () => { + const { conditions } = testIntegration(); + + expect(conditions.testRule1('a', 1)).toEqual({ + rule: 'testRule1', + params: ['a', 1], + }); + + expect(conditions.testRule2({ baz: 'quux' })).toEqual({ + rule: 'testRule2', + params: [{ baz: 'quux' }], + }); + }); + }); + + describe('createPolicyDecisions', () => { + it('wraps conditions in an object with resourceType and pluginId', () => { + const { createPolicyDecision } = testIntegration(); + + expect( + createPolicyDecision({ + allOf: [{ rule: 'testRule1', params: ['a', 1] }], + }), + ).toEqual({ + result: AuthorizeResult.CONDITIONAL, + pluginId: 'test-plugin', + resourceType: 'test-resource', + conditions: { + allOf: [{ rule: 'testRule1', params: ['a', 1] }], + }, + }); + }); + }); +}); diff --git a/plugins/permission-node/src/integration/createConditionExports.ts b/plugins/permission-node/src/integration/createConditionExports.ts new file mode 100644 index 0000000000..fd351128ed --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionExports.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AuthorizeResult, + PermissionCondition, + PermissionCriteria, +} from '@backstage/plugin-permission-common'; +import { ConditionalPolicyDecision } from '../policy'; +import { PermissionRule } from '../types'; +import { createConditionFactory } from './createConditionFactory'; + +/** + * A utility type for mapping a single {@link PermissionRule} to its + * corresponding {@link @backstage/plugin-permission-common#PermissionCondition}. + * + * @public + */ +export type Condition = TRule extends PermissionRule< + any, + any, + infer TParams +> + ? (...params: TParams) => PermissionCondition + : never; + +/** + * A utility type for mapping {@link PermissionRule}s to their corresponding + * {@link @backstage/plugin-permission-common#PermissionCondition}s. + * + * @public + */ +export type Conditions< + TRules extends Record>, +> = { + [Name in keyof TRules]: Condition; +}; + +/** + * Creates the recommended condition-related exports for a given plugin based on the built-in + * {@link PermissionRule}s it supports. + * + * @remarks + * + * The function returns a `conditions` object containing a + * {@link @backstage/plugin-permission-common#PermissionCondition} factory for each of the + * supplied {@link PermissionRule}s, along with a `createConditions` function which builds the + * wrapper object needed to enclose conditions when authoring {@link PermissionPolicy} implementations. + * + * Plugin authors should generally call this method with all the built-in {@link PermissionRule}s + * the plugin supports, and export the resulting `conditions` object and `createConditions` + * function so that they can be used by {@link PermissionPolicy} authors. + * + * @public + */ +export const createConditionExports = < + TResource, + TRules extends Record>, +>(options: { + pluginId: string; + resourceType: string; + rules: TRules; +}): { + conditions: Conditions; + createPolicyDecision: ( + conditions: PermissionCriteria, + ) => ConditionalPolicyDecision; +} => { + const { pluginId, resourceType, rules } = options; + + return { + conditions: Object.entries(rules).reduce( + (acc, [key, rule]) => ({ + ...acc, + [key]: createConditionFactory(rule), + }), + {} as Conditions, + ), + createPolicyDecision: ( + conditions: PermissionCriteria, + ) => ({ + result: AuthorizeResult.CONDITIONAL, + pluginId, + resourceType, + conditions, + }), + }; +}; diff --git a/plugins/permission-node/src/integration/createConditionFactory.test.ts b/plugins/permission-node/src/integration/createConditionFactory.test.ts new file mode 100644 index 0000000000..8dd43f5da6 --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionFactory.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createConditionFactory } from './createConditionFactory'; + +describe('createConditionFactory', () => { + const testRule = { + name: 'test-rule', + description: 'test-description', + apply: jest.fn(), + toQuery: jest.fn(), + }; + + it('returns a function', () => { + expect(createConditionFactory(testRule)).toEqual(expect.any(Function)); + }); + + describe('return value', () => { + it('constructs a condition with the rule name and supplied params', () => { + const conditionFactory = createConditionFactory(testRule); + expect(conditionFactory('a', 'b', 1, 2)).toEqual({ + rule: 'test-rule', + params: ['a', 'b', 1, 2], + }); + }); + }); +}); diff --git a/plugins/permission-node/src/integration/createConditionFactory.ts b/plugins/permission-node/src/integration/createConditionFactory.ts new file mode 100644 index 0000000000..84a8dc86a6 --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionFactory.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PermissionRule } from '../types'; + +/** + * Creates a condition factory function for a given authorization rule and parameter types. + * + * @remarks + * + * For example, an isEntityOwner rule for catalog entities might take an array of entityRef strings. + * The rule itself defines _how_ to check a given resource, whereas a condition also includes _what_ + * to verify. + * + * Plugin authors should generally use the {@link createConditionExports} in order to efficiently + * create multiple condition factories. This helper should generally only be used to construct + * condition factories for third-party rules that aren't part of the backend plugin with which + * they're intended to integrate. + * + * @public + */ +export const createConditionFactory = + (rule: PermissionRule) => + (...params: TParams) => ({ + rule: rule.name, + params, + }); diff --git a/plugins/permission-node/src/integration/createConditionTransformer.test.ts b/plugins/permission-node/src/integration/createConditionTransformer.test.ts new file mode 100644 index 0000000000..8a93a4ea27 --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionTransformer.test.ts @@ -0,0 +1,158 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + PermissionCondition, + PermissionCriteria, +} from '@backstage/plugin-permission-common'; +import { createConditionTransformer } from './createConditionTransformer'; + +const transformConditions = createConditionTransformer([ + { + name: 'test-rule-1', + description: 'Test rule 1', + apply: jest.fn(), + toQuery: jest.fn( + (firstParam: string, secondParam: number) => + `test-rule-1:${firstParam}/${secondParam}`, + ), + }, + { + name: 'test-rule-2', + description: 'Test rule 2', + apply: jest.fn(), + toQuery: jest.fn( + (firstParam: object) => `test-rule-2:${JSON.stringify(firstParam)}`, + ), + }, +]); + +describe('createConditionTransformer', () => { + const testCases: { + conditions: PermissionCriteria; + expectedResult: PermissionCriteria; + }[] = [ + { + conditions: { rule: 'test-rule-1', params: ['abc', 123] }, + expectedResult: 'test-rule-1:abc/123', + }, + { + conditions: { rule: 'test-rule-2', params: [{ foo: 0 }] }, + expectedResult: 'test-rule-2:{"foo":0}', + }, + { + conditions: { + anyOf: [ + { rule: 'test-rule-1', params: ['a', 1] }, + { rule: 'test-rule-2', params: [{}] }, + ], + }, + expectedResult: { + anyOf: ['test-rule-1:a/1', 'test-rule-2:{}'], + }, + }, + { + conditions: { + allOf: [ + { rule: 'test-rule-1', params: ['a', 1] }, + { rule: 'test-rule-2', params: [{}] }, + ], + }, + expectedResult: { + allOf: ['test-rule-1:a/1', 'test-rule-2:{}'], + }, + }, + { + conditions: { + not: { rule: 'test-rule-2', params: [{}] }, + }, + expectedResult: { + not: 'test-rule-2:{}', + }, + }, + { + conditions: { + allOf: [ + { + anyOf: [ + { rule: 'test-rule-1', params: ['a', 1] }, + { rule: 'test-rule-2', params: [{}] }, + ], + }, + { + not: { + allOf: [ + { rule: 'test-rule-1', params: ['b', 2] }, + { rule: 'test-rule-2', params: [{ c: 3 }] }, + ], + }, + }, + ], + }, + expectedResult: { + allOf: [ + { + anyOf: ['test-rule-1:a/1', 'test-rule-2:{}'], + }, + { + not: { + allOf: ['test-rule-1:b/2', 'test-rule-2:{"c":3}'], + }, + }, + ], + }, + }, + { + conditions: { + allOf: [ + { + anyOf: [ + { rule: 'test-rule-1', params: ['a', 1] }, + { rule: 'test-rule-2', params: [{ b: 2 }] }, + ], + }, + { + not: { + allOf: [ + { rule: 'test-rule-1', params: ['c', 3] }, + { not: { rule: 'test-rule-2', params: [{ d: 4 }] } }, + ], + }, + }, + ], + }, + expectedResult: { + allOf: [ + { + anyOf: ['test-rule-1:a/1', 'test-rule-2:{"b":2}'], + }, + { + not: { + allOf: ['test-rule-1:c/3', { not: 'test-rule-2:{"d":4}' }], + }, + }, + ], + }, + }, + ]; + + it.each(testCases)( + 'works with criteria %#', + ({ conditions, expectedResult }) => { + expect(transformConditions(conditions)).toEqual(expectedResult); + }, + ); +}); diff --git a/plugins/permission-node/src/integration/createConditionTransformer.ts b/plugins/permission-node/src/integration/createConditionTransformer.ts new file mode 100644 index 0000000000..0e736b832c --- /dev/null +++ b/plugins/permission-node/src/integration/createConditionTransformer.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + PermissionCondition, + PermissionCriteria, +} from '@backstage/plugin-permission-common'; +import { PermissionRule } from '../types'; +import { + createGetRule, + isAndCriteria, + isNotCriteria, + isOrCriteria, +} from './util'; + +const mapConditions = ( + criteria: PermissionCriteria, + getRule: (name: string) => PermissionRule, +): PermissionCriteria => { + if (isAndCriteria(criteria)) { + return { + allOf: criteria.allOf.map(child => mapConditions(child, getRule)), + }; + } else if (isOrCriteria(criteria)) { + return { + anyOf: criteria.anyOf.map(child => mapConditions(child, getRule)), + }; + } else if (isNotCriteria(criteria)) { + return { + not: mapConditions(criteria.not, getRule), + }; + } + + return getRule(criteria.rule).toQuery(...criteria.params); +}; + +/** + * A function which accepts {@link @backstage/plugin-permission-common#PermissionCondition}s + * logically grouped in a {@link @backstage/plugin-permission-common#PermissionCriteria} + * object, and transforms the {@link @backstage/plugin-permission-common#PermissionCondition}s + * into plugin specific query fragments while retaining the enclosing criteria shape. + * + * @public + */ +export type ConditionTransformer = ( + conditions: PermissionCriteria, +) => PermissionCriteria; + +/** + * A higher-order helper function which accepts an array of + * {@link PermissionRule}s, and returns a {@link ConditionTransformer} + * which transforms input conditions into equivalent plugin-specific + * query fragments using the supplied rules. + * + * @public + */ +export const createConditionTransformer = < + TQuery, + TRules extends PermissionRule[], +>( + permissionRules: [...TRules], +): ConditionTransformer => { + const getRule = createGetRule(permissionRules); + + return conditions => mapConditions(conditions, getRule); +}; diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts new file mode 100644 index 0000000000..15c9cf4003 --- /dev/null +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.test.ts @@ -0,0 +1,210 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import express, { Express, Router } from 'express'; +import request from 'supertest'; +import { createPermissionIntegrationRouter } from './createPermissionIntegrationRouter'; + +const mockGetResource: jest.MockedFunction< + (resourceRef: string) => Promise +> = jest.fn((resourceRef: string) => + Promise.resolve({ + resourceRef, + }), +); + +const testRule1 = { + name: 'test-rule-1', + description: 'Test rule 1', + apply: jest.fn( + (_resource: any, _firstParam: string, _secondParam: number) => true, + ), + toQuery: jest.fn(), +}; + +const testRule2 = { + name: 'test-rule-2', + description: 'Test rule 2', + apply: jest.fn((_firstParam: object) => false), + toQuery: jest.fn(), +}; + +describe('createPermissionIntegrationRouter', () => { + let app: Express; + let router: Router; + + beforeEach(() => { + router = createPermissionIntegrationRouter({ + resourceType: 'test-resource', + getResource: mockGetResource, + rules: [testRule1, testRule2], + }); + + app = express().use(router); + }); + + it('works', async () => { + expect(router).toBeDefined(); + }); + + describe('POST /.well-known/backstage/permissions/apply-conditions', () => { + it.each([ + { rule: 'test-rule-1', params: ['abc', 123] }, + { + anyOf: [ + { rule: 'test-rule-1', params: ['a', 1] }, + { rule: 'test-rule-2', params: [{}] }, + ], + }, + + { + not: { rule: 'test-rule-2', params: [{}] }, + }, + { + allOf: [ + { + anyOf: [ + { rule: 'test-rule-1', params: ['a', 1] }, + { rule: 'test-rule-2', params: [{}] }, + ], + }, + { + not: { + allOf: [ + { rule: 'test-rule-1', params: ['b', 2] }, + { rule: 'test-rule-2', params: [{ c: 3 }] }, + ], + }, + }, + ], + }, + ])('returns 200/ALLOW when criteria match (case %#)', async conditions => { + const response = await request(app) + .post('/.well-known/backstage/permissions/apply-conditions') + .send({ + resourceRef: 'default:test/resource', + resourceType: 'test-resource', + conditions, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ result: AuthorizeResult.ALLOW }); + }); + + it.each([ + { rule: 'test-rule-2', params: [{ foo: 0 }] }, + { + allOf: [ + { rule: 'test-rule-1', params: ['a', 1] }, + { rule: 'test-rule-2', params: [{}] }, + ], + }, + { + allOf: [ + { + anyOf: [ + { rule: 'test-rule-1', params: ['a', 1] }, + { rule: 'test-rule-2', params: [{ b: 2 }] }, + ], + }, + { + not: { + allOf: [ + { rule: 'test-rule-1', params: ['c', 3] }, + { not: { rule: 'test-rule-2', params: [{ d: 4 }] } }, + ], + }, + }, + ], + }, + ])( + 'returns 200/DENY when criteria do not match (case %#)', + async conditions => { + const response = await request(app) + .post('/.well-known/backstage/permissions/apply-conditions') + .send({ + resourceRef: 'default:test/resource', + resourceType: 'test-resource', + conditions, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ result: AuthorizeResult.DENY }); + }, + ); + + it('returns 400 when called with incorrect resource type', async () => { + const response = await request(app) + .post('/.well-known/backstage/permissions/apply-conditions') + .send({ + resourceRef: 'default:test/resource', + resourceType: 'test-incorrect-resource', + conditions: { + anyOf: [], + }, + }); + + expect(response.status).toEqual(400); + expect(response.error && response.error.text).toMatch( + /unexpected resource type: test-incorrect-resource/i, + ); + }); + + it('returns 400 when resource is not found', async () => { + mockGetResource.mockReturnValueOnce(Promise.resolve(undefined)); + + const response = await request(app) + .post('/.well-known/backstage/permissions/apply-conditions') + .send({ + resourceRef: 'default:test/resource', + resourceType: 'test-resource', + conditions: { + not: { + rule: 'testRule1', + params: ['a', 1], + }, + }, + }); + + expect(response.status).toEqual(400); + expect(response.error && response.error.text).toMatch( + /resource for ref default:test\/resource not found/i, + ); + }); + + it.each([ + undefined, + {}, + { resourceType: 'test-resource-type' }, + { resourceRef: 'test/resource-ref' }, + { + resourceType: 'test-resource-type', + resourceRef: 'test/resource-ref', + }, + { conditions: { anyOf: [] } }, + ])(`returns 400 for invalid input %#`, async input => { + const response = await request(app) + .post('/.well-known/backstage/permissions/apply-conditions') + .send(input); + + expect(response.status).toEqual(400); + expect(response.error && response.error.text).toMatch( + /invalid request body/i, + ); + }); + }); +}); diff --git a/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts new file mode 100644 index 0000000000..70b72fef7e --- /dev/null +++ b/plugins/permission-node/src/integration/createPermissionIntegrationRouter.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, { Response, Router } from 'express'; +import { z } from 'zod'; +import { + AuthorizeResult, + PermissionCondition, + PermissionCriteria, +} from '@backstage/plugin-permission-common'; +import { PermissionRule } from '../types'; +import { + createGetRule, + isAndCriteria, + isNotCriteria, + isOrCriteria, +} from './util'; + +const permissionCriteriaSchema: z.ZodSchema< + PermissionCriteria +> = z.lazy(() => + z.union([ + z.object({ anyOf: z.array(permissionCriteriaSchema) }), + z.object({ allOf: z.array(permissionCriteriaSchema) }), + z.object({ not: permissionCriteriaSchema }), + z.object({ + rule: z.string(), + params: z.array(z.unknown()), + }), + ]), +); + +const applyConditionsRequestSchema = z.object({ + resourceRef: z.string(), + resourceType: z.string(), + conditions: permissionCriteriaSchema, +}); + +/** + * A request to load the referenced resource and apply conditions in order to + * finalize a conditional authorization response. + * + * @public + */ +export type ApplyConditionsRequest = { + resourceRef: string; + resourceType: string; + conditions: PermissionCriteria; +}; + +/** + * The result of applying the conditions, expressed as a definitive authorize + * result of ALLOW or DENY. + * + * @public + */ +export type ApplyConditionsResponse = { + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; +}; + +const applyConditions = ( + criteria: PermissionCriteria, + resource: TResource, + getRule: (name: string) => PermissionRule, +): boolean => { + if (isAndCriteria(criteria)) { + return criteria.allOf.every(child => + applyConditions(child, resource, getRule), + ); + } else if (isOrCriteria(criteria)) { + return criteria.anyOf.some(child => + applyConditions(child, resource, getRule), + ); + } else if (isNotCriteria(criteria)) { + return !applyConditions(criteria.not, resource, getRule); + } + + return getRule(criteria.rule).apply(resource, ...criteria.params); +}; + +/** + * Create an express Router which provides an authorization route to allow integration between the + * permission backend and other Backstage backend plugins. Plugin owners that wish to support + * conditional authorization for their resources should add the router created by this function + * to their express app inside their `createRouter` implementation. + * + * @remarks + * + * To make this concrete, we can use the Backstage software catalog as an example. The catalog has + * conditional rules around access to specific _entities_ in the catalog. The _type_ of resource is + * captured here as `resourceType`, a string identifier (`catalog-entity` in this example) that can + * be provided with permission definitions. This is merely a _type_ to verify that conditions in an + * authorization policy are constructed correctly, not a reference to a specific resource. + * + * The `rules` parameter is an array of {@link PermissionRule}s that introduce conditional + * filtering logic for resources; for the catalog, these are things like `isEntityOwner` or + * `hasAnnotation`. Rules describe how to filter a list of resources, and the `conditions` returned + * allow these rules to be applied with specific parameters (such as 'group:default/team-a', or + * 'backstage.io/edit-url'). + * + * The `getResource` argument should load a resource by reference. For the catalog, this is an + * {@link @backstage/catalog-model#EntityRef}. For other plugins, this can be any serialized format. + * This is used to construct the `createPermissionIntegrationRouter`, a function to add an + * authorization route to your backend plugin. This route will be called by the `permission-backend` + * when authorization conditions relating to this plugin need to be evaluated. + * @public + */ +export const createPermissionIntegrationRouter = ({ + resourceType, + rules, + getResource, +}: { + resourceType: string; + rules: PermissionRule[]; + getResource: (resourceRef: string) => Promise; +}): Router => { + const router = Router(); + + const getRule = createGetRule(rules); + + router.post( + '/.well-known/backstage/permissions/apply-conditions', + express.json(), + async ( + req, + res: Response< + | { + result: Omit; + } + | string + >, + ) => { + const parseResult = applyConditionsRequestSchema.safeParse(req.body); + + if (!parseResult.success) { + return res.status(400).send(`Invalid request body.`); + } + + const { data: body } = parseResult; + + if (body.resourceType !== resourceType) { + return res + .status(400) + .send(`Unexpected resource type: ${body.resourceType}.`); + } + + const resource = await getResource(body.resourceRef); + + if (!resource) { + return res + .status(400) + .send(`Resource for ref ${body.resourceRef} not found.`); + } + + return res.status(200).json({ + result: applyConditions(body.conditions, resource, getRule) + ? AuthorizeResult.ALLOW + : AuthorizeResult.DENY, + }); + }, + ); + + return router; +}; diff --git a/plugins/permission-node/src/integration/index.ts b/plugins/permission-node/src/integration/index.ts new file mode 100644 index 0000000000..f070d57c8f --- /dev/null +++ b/plugins/permission-node/src/integration/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './createConditionFactory'; +export * from './createConditionExports'; +export * from './createConditionTransformer'; +export * from './createPermissionIntegrationRouter'; diff --git a/plugins/permission-node/src/integration/util.test.ts b/plugins/permission-node/src/integration/util.test.ts new file mode 100644 index 0000000000..d27c0a4243 --- /dev/null +++ b/plugins/permission-node/src/integration/util.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createGetRule, + isAndCriteria, + isNotCriteria, + isOrCriteria, +} from './util'; + +describe('permission integration utils', () => { + describe('createGetRule', () => { + let getRule: ReturnType; + + const testRule1 = { + name: 'test-rule-1', + description: 'Test rule 1', + apply: jest.fn(), + toQuery: jest.fn(), + }; + + const testRule2 = { + name: 'test-rule-2', + description: 'Test rule 2', + apply: jest.fn(), + toQuery: jest.fn(), + }; + + beforeEach(() => { + getRule = createGetRule([testRule1, testRule2]); + }); + + it('returns the rule matching the supplied name', () => { + expect(getRule('test-rule-1')).toBe(testRule1); + }); + + it('throws if there is no rule for the supplied name', () => { + expect(() => getRule('test-rule-3')).toThrowError( + /unexpected permission rule/i, + ); + }); + }); + + describe('isOrCriteria', () => { + it('returns true if input has a top-level "anyOf" property', () => { + expect(isOrCriteria({ anyOf: { not: { allOf: [] } } })).toEqual(true); + }); + + it('returns false if input does not have a top-level "anyOf" property', () => { + expect(isOrCriteria({ allOf: { not: { anyOf: [] } } })).toEqual(false); + }); + }); + + describe('isAndCriteria', () => { + it('returns true if input has a top-level "allOf" property', () => { + expect(isAndCriteria({ allOf: { not: { anyOf: [] } } })).toEqual(true); + }); + + it('returns false if input does not have a top-level "allOf" property', () => { + expect(isAndCriteria({ anyOf: { not: { allOf: [] } } })).toEqual(false); + }); + }); + + describe('isNotCriteria', () => { + it('returns true if input has a top-level "not" property', () => { + expect(isNotCriteria({ not: { allOf: [{ anyOf: [] }] } })).toEqual(true); + }); + + it('returns false if input does not have a top-level "not" property', () => { + expect(isNotCriteria({ anyOf: { not: { allOf: [] } } })).toEqual(false); + }); + }); +}); diff --git a/plugins/permission-node/src/integration/util.ts b/plugins/permission-node/src/integration/util.ts new file mode 100644 index 0000000000..d9457cfefc --- /dev/null +++ b/plugins/permission-node/src/integration/util.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PermissionCriteria } from '@backstage/plugin-permission-common'; +import { PermissionRule } from '../types'; + +export const isAndCriteria = ( + filter: PermissionCriteria, +): filter is { allOf: PermissionCriteria[] } => + Object.prototype.hasOwnProperty.call(filter, 'allOf'); + +export const isOrCriteria = ( + filter: PermissionCriteria, +): filter is { anyOf: PermissionCriteria[] } => + Object.prototype.hasOwnProperty.call(filter, 'anyOf'); + +export const isNotCriteria = ( + filter: PermissionCriteria, +): filter is { not: PermissionCriteria } => + Object.prototype.hasOwnProperty.call(filter, 'not'); + +export const createGetRule = ( + rules: PermissionRule[], +) => { + const rulesMap = new Map(Object.values(rules).map(rule => [rule.name, rule])); + + return (name: string): PermissionRule => { + const rule = rulesMap.get(name); + + if (!rule) { + throw new Error(`Unexpected permission rule: ${name}`); + } + + return rule; + }; +}; diff --git a/plugins/permission-node/src/policy/index.ts b/plugins/permission-node/src/policy/index.ts new file mode 100644 index 0000000000..c8216989a1 --- /dev/null +++ b/plugins/permission-node/src/policy/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + ConditionalPolicyDecision, + PermissionPolicy, + PolicyAuthorizeRequest, + PolicyDecision, +} from './types'; diff --git a/plugins/permission-node/src/policy/types.ts b/plugins/permission-node/src/policy/types.ts new file mode 100644 index 0000000000..3548d051f6 --- /dev/null +++ b/plugins/permission-node/src/policy/types.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AuthorizeRequest, + AuthorizeResult, + PermissionCondition, + PermissionCriteria, +} from '@backstage/plugin-permission-common'; +import { BackstageIdentity } from '@backstage/plugin-auth-backend'; + +/** + * An authorization request to be evaluated by the {@link PermissionPolicy}. + * + * @remarks + * + * This differs from {@link @backstage/permission-common#AuthorizeRequest} in that `resourceRef` + * should never be provided. This forces policies to be written in a way that's compatible with + * filtering collections of resources at data load time. + * + * @public + */ +export type PolicyAuthorizeRequest = Omit; + +/** + * A conditional result to an authorization request, returned by the {@link PermissionPolicy}. + * + * @remarks + * + * This indicates that the policy allows authorization for the request, given that the returned + * conditions hold when evaluated. The conditions will be evaluated by the corresponding plugin + * which knows about the referenced permission rules. + * + * Similar to {@link @backstage/permission-common#AuthorizeResult}, but with the plugin and resource + * identifiers needed to evaluate the returned conditions. + * @public + */ +export type ConditionalPolicyDecision = { + result: AuthorizeResult.CONDITIONAL; + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; +}; + +/** + * The result of evaluating an authorization request with a {@link PermissionPolicy}. + * + * @public + */ +export type PolicyDecision = + | { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY } + | ConditionalPolicyDecision; + +/** + * A policy to evaluate authorization requests for any permissioned action performed in Backstage. + * + * @remarks + * + * This takes as input a permission and an optional Backstage identity, and should return ALLOW if + * the user is permitted to execute that action; otherwise DENY. For permissions relating to + * resources, such a catalog entities, a conditional response can also be returned. This states + * that the action is allowed if the conditions provided hold true. + * + * Conditions are a rule, and parameters to evaluate against that rule. For example, the rule might + * be `isOwner` and the parameters a collection of entityRefs; if one of the entityRefs matches + * the `owner` field on a catalog entity, this would resolve to ALLOW. + * + * @public + */ +export interface PermissionPolicy { + handle( + request: PolicyAuthorizeRequest, + user?: BackstageIdentity, + ): Promise; +} diff --git a/plugins/permission-node/src/setupTests.ts b/plugins/permission-node/src/setupTests.ts new file mode 100644 index 0000000000..a330613afb --- /dev/null +++ b/plugins/permission-node/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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/permission-node/src/types.ts b/plugins/permission-node/src/types.ts new file mode 100644 index 0000000000..678befc99c --- /dev/null +++ b/plugins/permission-node/src/types.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PermissionCriteria } from '@backstage/plugin-permission-common'; + +/** + * A conditional rule that can be provided in an + * {@link @backstage/permission-common#AuthorizeResult} response to an authorization request. + * + * @remarks + * + * Rules can either be evaluated against a resource loaded in memory, or used as filters when + * loading a collection of resources from a data source. The `apply` and `toQuery` methods implement + * these two concepts. + * + * The two operations should always have the same logical result. If they don’t, the effective + * outcome of an authorization operation will sometimes differ depending on how the authorization + * check was performed. + * + * @public + */ +export type PermissionRule< + TResource, + TQuery, + TParams extends unknown[] = unknown[], +> = { + name: string; + description: string; + + /** + * Apply this rule to a resource already loaded from a backing data source. The params are + * arguments supplied for the rule; for example, a rule could be `isOwner` with entityRefs as the + * params. + */ + apply(resource: TResource, ...params: TParams): boolean; + + /** + * Translate this rule to criteria suitable for use in querying a backing data store. The criteria + * can be used for loading a collection of resources efficiently with conditional criteria already + * applied. + */ + toQuery(...params: TParams): PermissionCriteria; +}; diff --git a/packages/test-utils-core/.eslintrc.js b/plugins/permission-react/.eslintrc.js similarity index 100% rename from packages/test-utils-core/.eslintrc.js rename to plugins/permission-react/.eslintrc.js diff --git a/plugins/permission-react/CHANGELOG.md b/plugins/permission-react/CHANGELOG.md new file mode 100644 index 0000000000..9592985354 --- /dev/null +++ b/plugins/permission-react/CHANGELOG.md @@ -0,0 +1,14 @@ +# @backstage/plugin-permission-react + +## 0.1.0 + +### Minor Changes + +- 6ed24445a9: Add @backstage/plugin-permission-react + + @backstage/plugin-permission-react is a library containing utils for implementing permissions in your frontend Backstage plugins. See [the authorization PRFC](https://github.com/backstage/backstage/pull/7761) for more details. + +### Patch Changes + +- Updated dependencies + - @backstage/core-plugin-api@0.2.2 diff --git a/plugins/permission-react/README.md b/plugins/permission-react/README.md new file mode 100644 index 0000000000..72383f024d --- /dev/null +++ b/plugins/permission-react/README.md @@ -0,0 +1,5 @@ +# permission + +**NOTE: THIS PACKAGE IS EXPERIMENTAL!** + +Components and hooks to help implement permissions in Backstage frontend plugins. For more information, see the [authorization PRFC](https://github.com/backstage/backstage/pull/7761). diff --git a/plugins/permission-react/api-report.md b/plugins/permission-react/api-report.md new file mode 100644 index 0000000000..65f93de8a0 --- /dev/null +++ b/plugins/permission-react/api-report.md @@ -0,0 +1,69 @@ +## API Report File for "@backstage/plugin-permission-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ApiRef } from '@backstage/core-plugin-api'; +import { AuthorizeRequest } from '@backstage/plugin-permission-common'; +import { AuthorizeResponse } from '@backstage/plugin-permission-common'; +import { Config } from '@backstage/config'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { IdentityApi } from '@backstage/core-plugin-api'; +import { Permission } from '@backstage/plugin-permission-common'; +import { default as React_2 } from 'react'; +import { RouteProps } from 'react-router'; + +// @public (undocumented) +export type AsyncPermissionResult = { + loading: boolean; + allowed: boolean; + error?: Error; +}; + +// @public +export class IdentityPermissionApi implements PermissionApi { + // (undocumented) + authorize(request: AuthorizeRequest): Promise; + // (undocumented) + static create({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }): IdentityPermissionApi; +} + +// @public +export type PermissionApi = { + authorize(request: AuthorizeRequest): Promise; +}; + +// @public +export const permissionApiRef: ApiRef; + +// @public +export const PermissionedRoute: ({ + permission, + resourceRef, + errorComponent, + ...props +}: RouteProps & { + permission: Permission; + resourceRef?: string | undefined; + errorComponent?: + | React_2.ReactElement> + | null + | undefined; +}) => JSX.Element; + +// @public +export const usePermission: ( + permission: Permission, + resourceRef?: string | undefined, +) => AsyncPermissionResult; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/permission-react/package.json b/plugins/permission-react/package.json new file mode 100644 index 0000000000..26c5f71da0 --- /dev/null +++ b/plugins/permission-react/package.json @@ -0,0 +1,49 @@ +{ + "name": "@backstage/plugin-permission-react", + "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/permission-react" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli build", + "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.11", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-permission-common": "^0.2.0", + "@types/react": "*", + "cross-fetch": "^3.0.6", + "react": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^0.10.0", + "@backstage/test-utils": "^0.1.22", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@types/jest": "^26.0.7" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts new file mode 100644 index 0000000000..a68060a61d --- /dev/null +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { PermissionApi } from './PermissionApi'; +import { + AuthorizeRequest, + AuthorizeResponse, + PermissionClient, +} from '@backstage/plugin-permission-common'; +import { Config } from '@backstage/config'; + +/** + * The default implementation of the PermissionApi, which simply calls the authorize method of the given + * {@link @backstage/plugin-permission-common#PermissionClient}. + * @public + */ +export class IdentityPermissionApi implements PermissionApi { + private constructor( + private readonly permissionClient: PermissionClient, + private readonly identityApi: IdentityApi, + ) {} + + static create({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + const permissionClient = new PermissionClient({ discoveryApi, configApi }); + return new IdentityPermissionApi(permissionClient, identityApi); + } + + async authorize(request: AuthorizeRequest): Promise { + const response = await this.permissionClient.authorize([request], { + token: await this.identityApi.getIdToken(), + }); + return response[0]; + } +} diff --git a/plugins/permission-react/src/apis/PermissionApi.ts b/plugins/permission-react/src/apis/PermissionApi.ts new file mode 100644 index 0000000000..5c224ff06d --- /dev/null +++ b/plugins/permission-react/src/apis/PermissionApi.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AuthorizeRequest, + AuthorizeResponse, +} from '@backstage/plugin-permission-common'; +import { ApiRef, createApiRef } from '@backstage/core-plugin-api'; + +/** + * This API is used by various frontend utilities that allow developers to implement authorization wihtin their frontend + * plugins. A plugin developer will likely not have to interact with this API or its implementations directly, but + * rather with the aforementioned utility components/hooks. + * @public + */ +export type PermissionApi = { + authorize(request: AuthorizeRequest): Promise; +}; + +/** + * A Backstage ApiRef for the Permission API. See https://backstage.io/docs/api/utility-apis for more information on + * Backstage ApiRefs. + * @public + */ +export const permissionApiRef: ApiRef = createApiRef({ + id: 'plugin.permission.api', +}); diff --git a/plugins/permission-react/src/apis/index.ts b/plugins/permission-react/src/apis/index.ts new file mode 100644 index 0000000000..223339f891 --- /dev/null +++ b/plugins/permission-react/src/apis/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { permissionApiRef } from './PermissionApi'; +export type { PermissionApi } from './PermissionApi'; +export { IdentityPermissionApi } from './IdentityPermissionApi'; diff --git a/plugins/permission-react/src/components/PermissionedRoute.test.tsx b/plugins/permission-react/src/components/PermissionedRoute.test.tsx new file mode 100644 index 0000000000..3d7d1b7af7 --- /dev/null +++ b/plugins/permission-react/src/components/PermissionedRoute.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { PermissionedRoute } from '.'; +import { usePermission } from '../hooks'; +import { renderInTestApp } from '@backstage/test-utils'; + +jest.mock('../hooks', () => ({ + usePermission: jest.fn(), +})); +const mockUsePermission = usePermission as jest.MockedFunction< + typeof usePermission +>; + +const permission = { + name: 'access.something', + attributes: { action: 'read' as const }, +}; + +describe('PermissionedRoute', () => { + it('Does not render when loading', async () => { + mockUsePermission.mockReturnValue({ loading: true, allowed: false }); + + const { queryByText } = await renderInTestApp( + content} + />, + ); + + expect(queryByText('content')).not.toBeTruthy(); + }); + + it('Renders given element if authorized', async () => { + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + + const { getByText } = await renderInTestApp( + content} + />, + ); + + expect(getByText('content')).toBeTruthy(); + }); + + it('Renders not found page if not authorized', async () => { + mockUsePermission.mockReturnValue({ loading: false, allowed: false }); + + await expect( + renderInTestApp( + content} + />, + ), + ).rejects.toThrowError('Reached NotFound Page'); + }); + + it('Renders custom error page if not authorized', async () => { + mockUsePermission.mockReturnValue({ loading: false, allowed: false }); + + const { getByText } = await renderInTestApp( + content} + errorComponent={

Custom Error

} + />, + ); + + expect(getByText('Custom Error')).toBeTruthy(); + }); +}); diff --git a/plugins/permission-react/src/components/PermissionedRoute.tsx b/plugins/permission-react/src/components/PermissionedRoute.tsx new file mode 100644 index 0000000000..1a3668205b --- /dev/null +++ b/plugins/permission-react/src/components/PermissionedRoute.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps, ReactElement } from 'react'; +import { Route } from 'react-router'; +import { useApp } from '@backstage/core-plugin-api'; +import { usePermission } from '../hooks'; +import { Permission } from '@backstage/plugin-permission-common'; + +/** + * Returns a React Router Route which only renders the element when authorized. If unathorized, the Route will render a + * NotFoundErrorPage (see {@link @backstage/core-app-api#AppComponents}). + * @public + */ +export const PermissionedRoute = ({ + permission, + resourceRef, + errorComponent, + ...props +}: ComponentProps & { + permission: Permission; + resourceRef?: string; + errorComponent?: ReactElement | null; +}) => { + const permissionResult = usePermission(permission, resourceRef); + const app = useApp(); + const { NotFoundErrorPage } = app.getComponents(); + + let shownElement: ReactElement | null | undefined = + errorComponent === undefined ? : errorComponent; + + if (permissionResult.loading) { + shownElement = null; + } else if (permissionResult.allowed) { + shownElement = props.element; + } + + return ; +}; diff --git a/plugins/permission-react/src/components/index.ts b/plugins/permission-react/src/components/index.ts new file mode 100644 index 0000000000..05f13bca81 --- /dev/null +++ b/plugins/permission-react/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { PermissionedRoute } from './PermissionedRoute'; diff --git a/plugins/permission-react/src/hooks/index.ts b/plugins/permission-react/src/hooks/index.ts new file mode 100644 index 0000000000..5fca80b89e --- /dev/null +++ b/plugins/permission-react/src/hooks/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { usePermission } from './usePermission'; +export type { AsyncPermissionResult } from './usePermission'; diff --git a/plugins/permission-react/src/hooks/usePermission.test.tsx b/plugins/permission-react/src/hooks/usePermission.test.tsx new file mode 100644 index 0000000000..4cf0bc6530 --- /dev/null +++ b/plugins/permission-react/src/hooks/usePermission.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC } from 'react'; +import { render } from '@testing-library/react'; +import { usePermission } from './usePermission'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { TestApiProvider } from '@backstage/test-utils'; +import { permissionApiRef } from '../apis'; + +const mockAuthorize = jest.fn(); + +const permission = { + name: 'access.something', + attributes: { action: 'read' as const }, +}; + +const TestComponent: FC = () => { + const { loading, allowed, error } = usePermission(permission); + return ( +
+ {loading && 'loading'} + {error && 'error'} + {allowed ? 'content' : null} +
+ ); +}; + +describe('usePermission', () => { + it('Returns loading when permissionApi has not yet responded.', () => { + mockAuthorize.mockReturnValueOnce(new Promise(() => {})); + + const { getByText } = render( + + + , + ); + + expect(mockAuthorize).toHaveBeenCalledWith({ permission }); + expect(getByText('loading')).toBeTruthy(); + }); + + it('Returns allowed when permissionApi allows authorization.', async () => { + mockAuthorize.mockResolvedValueOnce({ result: AuthorizeResult.ALLOW }); + + const { findByText } = render( + + + , + ); + + expect(mockAuthorize).toHaveBeenCalledWith({ permission }); + expect(await findByText('content')).toBeTruthy(); + }); + + it('Returns not allowed when permissionApi denies authorization.', async () => { + mockAuthorize.mockResolvedValueOnce({ result: AuthorizeResult.DENY }); + + const { findByText } = render( + + + , + ); + + expect(mockAuthorize).toHaveBeenCalledWith({ permission }); + await expect(findByText('content')).rejects.toThrowError(); + }); +}); diff --git a/plugins/permission-react/src/hooks/usePermission.ts b/plugins/permission-react/src/hooks/usePermission.ts new file mode 100644 index 0000000000..129904d7df --- /dev/null +++ b/plugins/permission-react/src/hooks/usePermission.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useAsync } from 'react-use'; +import { useApi } from '@backstage/core-plugin-api'; +import { permissionApiRef } from '../apis'; +import { + AuthorizeResult, + Permission, +} from '@backstage/plugin-permission-common'; + +/** @public */ +export type AsyncPermissionResult = { + loading: boolean; + allowed: boolean; + error?: Error; +}; + +/** + * React hook utlity for authorization. Given a {@link @backstage/plugin-permission-common#Permission} and an optional + * resourceRef, it will return whether or not access is allowed (for the given resource, if resourceRef is provided). See + * {@link @backstage/plugin-permission-common/PermissionClient#authorize} for more details. + * @public + */ +export const usePermission = ( + permission: Permission, + resourceRef?: string, +): AsyncPermissionResult => { + const permissionApi = useApi(permissionApiRef); + + const { loading, error, value } = useAsync(async () => { + const { result } = await permissionApi.authorize({ + permission, + resourceRef, + }); + + return result; + }, [permissionApi, permission, resourceRef]); + + if (loading) { + return { loading: true, allowed: false }; + } + if (error) { + return { error, loading: false, allowed: false }; + } + return { loading: false, allowed: value === AuthorizeResult.ALLOW }; +}; diff --git a/plugins/permission-react/src/index.ts b/plugins/permission-react/src/index.ts new file mode 100644 index 0000000000..57352c5cdd --- /dev/null +++ b/plugins/permission-react/src/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './components'; +export * from './hooks'; +export * from './apis'; diff --git a/plugins/permission-react/src/setupTests.ts b/plugins/permission-react/src/setupTests.ts new file mode 100644 index 0000000000..992b60d3a4 --- /dev/null +++ b/plugins/permission-react/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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'; diff --git a/plugins/proxy-backend/CHANGELOG.md b/plugins/proxy-backend/CHANGELOG.md index a7408c161f..57b3441fa5 100644 --- a/plugins/proxy-backend/CHANGELOG.md +++ b/plugins/proxy-backend/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-proxy-backend +## 0.2.14 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/backend-common@0.9.11 + ## 0.2.13 ### Patch Changes diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index c43ea8b31a..68be26391e 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-proxy-backend", "description": "A Backstage backend plugin that helps you set up proxy endpoints in the backend", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.8", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -43,7 +43,7 @@ "yup": "^0.32.9" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/proxy-backend/src/run.ts b/plugins/proxy-backend/src/run.ts index 54d2716290..0a3ed2b7f0 100644 --- a/plugins/proxy-backend/src/run.ts +++ b/plugins/proxy-backend/src/run.ts @@ -18,7 +18,7 @@ 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 port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index df999dbe2a..ddb524d3aa 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -34,9 +34,9 @@ describe('createRouter', () => { const logger = getVoidLogger(); const config = new ConfigReader({ backend: { - baseUrl: 'https://example.com:7000', + baseUrl: 'https://example.com:7007', listen: { - port: 7000, + port: 7007, }, }, }); diff --git a/plugins/rollbar-backend/CHANGELOG.md b/plugins/rollbar-backend/CHANGELOG.md index d2bc07ab7e..9c0f70ea6a 100644 --- a/plugins/rollbar-backend/CHANGELOG.md +++ b/plugins/rollbar-backend/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-rollbar-backend +## 0.1.16 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/backend-common@0.9.11 + ## 0.1.15 ### Patch Changes diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 1d7be09b85..96c1002e5e 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-rollbar-backend", "description": "A Backstage backend plugin that integrates towards Rollbar", - "version": "0.1.15", + "version": "0.1.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.10", "@types/express": "^4.17.6", "axios": "^0.21.1", @@ -48,7 +48,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/rollbar-backend/src/run.ts b/plugins/rollbar-backend/src/run.ts index 54d2716290..0a3ed2b7f0 100644 --- a/plugins/rollbar-backend/src/run.ts +++ b/plugins/rollbar-backend/src/run.ts @@ -18,7 +18,7 @@ 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 port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/rollbar/CHANGELOG.md b/plugins/rollbar/CHANGELOG.md index 4f7f3c9a16..aee0a74525 100644 --- a/plugins/rollbar/CHANGELOG.md +++ b/plugins/rollbar/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-rollbar +## 0.3.19 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.18 ### Patch Changes diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 2e2da0a284..d71c5e1cfe 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-rollbar", "description": "A Backstage plugin that integrates towards Rollbar", - "version": "0.3.18", + "version": "0.3.19", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,11 +32,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -49,10 +49,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/rollbar/src/components/Router.tsx b/plugins/rollbar/src/components/Router.tsx index 845da22a20..032bf0166e 100644 --- a/plugins/rollbar/src/components/Router.tsx +++ b/plugins/rollbar/src/components/Router.tsx @@ -19,7 +19,6 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import React from 'react'; import { Route, Routes } from 'react-router'; import { ROLLBAR_ANNOTATION } from '../constants'; -import { rootRouteRef } from '../plugin'; import { EntityPageRollbar } from './EntityPageRollbar/EntityPageRollbar'; import { MissingAnnotationEmptyState } from '@backstage/core-components'; @@ -40,10 +39,7 @@ export const Router = (_props: Props) => { return ( - } - /> + } /> ); }; diff --git a/plugins/rollbar/src/plugin.ts b/plugins/rollbar/src/plugin.ts index b0de6510f3..3ca456058f 100644 --- a/plugins/rollbar/src/plugin.ts +++ b/plugins/rollbar/src/plugin.ts @@ -26,8 +26,7 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '', - title: 'Rollbar', + id: 'rollbar', }); export const rollbarPlugin = createPlugin({ diff --git a/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md b/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md index 43a522482f..b177521d8b 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md +++ b/plugins/scaffolder-backend-module-cookiecutter/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-scaffolder-backend-module-cookiecutter +## 0.1.5 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/plugin-scaffolder-backend@0.15.15 + - @backstage/backend-common@0.9.12 + ## 0.1.4 ### Patch Changes diff --git a/plugins/scaffolder-backend-module-cookiecutter/package.json b/plugins/scaffolder-backend-module-cookiecutter/package.json index 36a6310aa2..bff3f49c83 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/package.json +++ b/plugins/scaffolder-backend-module-cookiecutter/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-scaffolder-backend-module-cookiecutter", "description": "A module for the scaffolder backend that lets you template projects using cookiecutter", - "version": "0.1.4", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,20 +20,19 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.9", + "@backstage/backend-common": "^0.9.12", "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", - "@backstage/plugin-scaffolder-backend": "^0.15.12", + "@backstage/integration": "^0.6.10", + "@backstage/plugin-scaffolder-backend": "^0.15.15", "@backstage/config": "^0.1.11", "@backstage/types": "^0.1.1", "command-exists": "^1.2.9", "fs-extra": "10.0.0", "winston": "^3.2.1", - "cross-fetch": "^3.0.6", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.2", + "@backstage/cli": "^0.10.0", "@types/fs-extra": "^9.0.1", "@types/mock-fs": "^4.13.0", "@types/jest": "^26.0.7", diff --git a/plugins/scaffolder-backend-module-rails/package.json b/plugins/scaffolder-backend-module-rails/package.json index f4ace02eca..cac3d7ea9b 100644 --- a/plugins/scaffolder-backend-module-rails/package.json +++ b/plugins/scaffolder-backend-module-rails/package.json @@ -21,17 +21,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.9", - "@backstage/plugin-scaffolder-backend": "^0.15.12", + "@backstage/backend-common": "^0.9.12", + "@backstage/plugin-scaffolder-backend": "^0.15.15", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", + "@backstage/integration": "^0.6.10", "@backstage/types": "^0.1.1", "command-exists": "^1.2.9", "fs-extra": "^9.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.2", + "@backstage/cli": "^0.10.0", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/command-exists": "^1.2.0", diff --git a/plugins/scaffolder-backend/.eslintrc.js b/plugins/scaffolder-backend/.eslintrc.js index 19c9ad7395..2632b5e089 100644 --- a/plugins/scaffolder-backend/.eslintrc.js +++ b/plugins/scaffolder-backend/.eslintrc.js @@ -1,8 +1,41 @@ +const parent = require('@backstage/cli/config/eslint.backend'); + module.exports = { extends: [require.resolve('@backstage/cli/config/eslint.backend')], ignorePatterns: ['sample-templates/'], rules: { 'no-console': 0, // Permitted in console programs 'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()' + // Usage of path.resolve is extra sensitive in the scaffolder, so forbid it in non-test code + 'no-restricted-imports': [ + 'error', + { + ...parent.rules['no-restricted-imports'][1], + paths: [ + { + name: 'path', + importNames: ['resolve'], + message: + 'Do not use path.resolve, use `resolveSafeChildPath` from `@backstage/backend-common` instead as it prevents security issues', + }, + ], + }, + ], + 'no-restricted-syntax': parent.rules['no-restricted-syntax'].concat([ + { + message: + 'Do not use path.resolve, use `resolveSafeChildPath` from `@backstage/backend-common` instead as it prevents security issues', + selector: + 'MemberExpression[object.name="path"][property.name="resolve"]', + }, + ]), }, + overrides: [ + { + files: ['*.test.*', 'src/setupTests.*', 'dev/**'], + rules: { + 'no-restricted-imports': parent.rules['no-restricted-imports'], + }, + }, + ], }; diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index de033eabbd..3b0add9cd6 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,43 @@ # @backstage/plugin-scaffolder-backend +## 0.15.15 + +### Patch Changes + +- 0398ea25d3: Removed unused scaffolder visibility configuration; this has been moved to publish actions. Deprecated scaffolder provider configuration keys; these should use the integrations configuration instead. +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- c6b44d80ad: Add options to spawn in runCommand helper +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/plugin-catalog-backend@0.19.0 + - @backstage/plugin-scaffolder-backend-module-cookiecutter@0.1.5 + - @backstage/backend-common@0.9.12 + +## 0.15.14 + +### Patch Changes + +- a096e4c4d7: Switched to executing scaffolder templating in a secure context for any template based on nunjucks, as it is [not secure by default](https://mozilla.github.io/nunjucks/api.html#user-defined-templates-warning). +- f9352ab606: Removed all usages of `path.resolve` in order to ensure that template paths are resolved in a safe way. +- e634a47ce5: Fix bug where there was error log lines written when failing to `JSON.parse` things that were not `JSON` values. +- 42ebbc18c0: Bump gitbeaker to the latest version +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/plugin-catalog-backend@0.18.0 + - @backstage/backend-common@0.9.11 + +## 0.15.13 + +### Patch Changes + +- 26eb174ce8: Skip empty file names when scaffolding with nunjucks +- ecdcbd08ee: Expose template metadata to custom action handler in Scaffolder. +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + - @backstage/plugin-catalog-backend@0.17.4 + ## 0.15.12 ### Patch Changes diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 3f9b77a3a3..6ed4625f66 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -24,6 +24,7 @@ import { PluginDatabaseManager } from '@backstage/backend-common'; import { Schema } from 'jsonschema'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { ScmIntegrations } from '@backstage/integration'; +import { SpawnOptionsWithoutStdio } from 'child_process'; import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; import { UrlReader } from '@backstage/backend-common'; import { Writable } from 'stream'; @@ -41,6 +42,7 @@ export type ActionContext = { input: Input; output(name: string, value: JsonValue): void; createTemporaryDirectory(): Promise; + metadata?: TemplateMetadata; }; // Warning: (ae-missing-release-tag) "CatalogEntityClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -303,11 +305,12 @@ export interface RouterOptions { // Warning: (ae-forgotten-export) The symbol "RunCommandOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "runCommand" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export const runCommand: ({ command, args, logStream, + options, }: RunCommandOptions) => Promise; // @public (undocumented) @@ -432,6 +435,8 @@ export interface TaskSpecV1beta2 { // (undocumented) baseUrl?: string; // (undocumented) + metadata?: TemplateMetadata; + // (undocumented) output: { [name: string]: string; }; @@ -454,6 +459,8 @@ export interface TaskSpecV1beta3 { // (undocumented) baseUrl?: string; // (undocumented) + metadata?: TemplateMetadata; + // (undocumented) output: { [name: string]: JsonValue; }; @@ -558,4 +565,9 @@ export class TemplateActionRegistry { action: TemplateAction, ): void; } + +// @public +export type TemplateMetadata = { + name: string; +}; ``` diff --git a/plugins/scaffolder-backend/assets/nunjucks.js.txt b/plugins/scaffolder-backend/assets/nunjucks.js.txt new file mode 100644 index 0000000000..b78d1d98ac --- /dev/null +++ b/plugins/scaffolder-backend/assets/nunjucks.js.txt @@ -0,0 +1,10385 @@ + +/** + * Copyright (c) 2012-2015, James Long + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +var __commonJS = (callback, module2) => () => { + if (!module2) { + module2 = {exports: {}}; + callback(module2.exports, module2); + } + return module2.exports; +}; + +// ../../node_modules/nunjucks/src/lib.js +var require_lib = __commonJS((exports2, module2) => { + "use strict"; + var ArrayProto = Array.prototype; + var ObjProto = Object.prototype; + var escapeMap = { + "&": "&", + '"': """, + "'": "'", + "<": "<", + ">": ">" + }; + var escapeRegex = /[&"'<>]/g; + var _exports = module2.exports = {}; + function hasOwnProp(obj, k) { + return ObjProto.hasOwnProperty.call(obj, k); + } + _exports.hasOwnProp = hasOwnProp; + function lookupEscape(ch) { + return escapeMap[ch]; + } + function _prettifyError(path, withInternals, err) { + if (!err.Update) { + err = new _exports.TemplateError(err); + } + err.Update(path); + if (!withInternals) { + var old = err; + err = new Error(old.message); + err.name = old.name; + } + return err; + } + _exports._prettifyError = _prettifyError; + function TemplateError(message, lineno, colno) { + var err; + var cause; + if (message instanceof Error) { + cause = message; + message = cause.name + ": " + cause.message; + } + if (Object.setPrototypeOf) { + err = new Error(message); + Object.setPrototypeOf(err, TemplateError.prototype); + } else { + err = this; + Object.defineProperty(err, "message", { + enumerable: false, + writable: true, + value: message + }); + } + Object.defineProperty(err, "name", { + value: "Template render error" + }); + if (Error.captureStackTrace) { + Error.captureStackTrace(err, this.constructor); + } + var getStack; + if (cause) { + var stackDescriptor = Object.getOwnPropertyDescriptor(cause, "stack"); + getStack = stackDescriptor && (stackDescriptor.get || function() { + return stackDescriptor.value; + }); + if (!getStack) { + getStack = function getStack2() { + return cause.stack; + }; + } + } else { + var stack = new Error(message).stack; + getStack = function getStack2() { + return stack; + }; + } + Object.defineProperty(err, "stack", { + get: function get() { + return getStack.call(err); + } + }); + Object.defineProperty(err, "cause", { + value: cause + }); + err.lineno = lineno; + err.colno = colno; + err.firstUpdate = true; + err.Update = function Update(path) { + var msg = "(" + (path || "unknown path") + ")"; + if (this.firstUpdate) { + if (this.lineno && this.colno) { + msg += " [Line " + this.lineno + ", Column " + this.colno + "]"; + } else if (this.lineno) { + msg += " [Line " + this.lineno + "]"; + } + } + msg += "\n "; + if (this.firstUpdate) { + msg += " "; + } + this.message = msg + (this.message || ""); + this.firstUpdate = false; + return this; + }; + return err; + } + if (Object.setPrototypeOf) { + Object.setPrototypeOf(TemplateError.prototype, Error.prototype); + } else { + TemplateError.prototype = Object.create(Error.prototype, { + constructor: { + value: TemplateError + } + }); + } + _exports.TemplateError = TemplateError; + function escape(val) { + return val.replace(escapeRegex, lookupEscape); + } + _exports.escape = escape; + function isFunction(obj) { + return ObjProto.toString.call(obj) === "[object Function]"; + } + _exports.isFunction = isFunction; + function isArray(obj) { + return ObjProto.toString.call(obj) === "[object Array]"; + } + _exports.isArray = isArray; + function isString(obj) { + return ObjProto.toString.call(obj) === "[object String]"; + } + _exports.isString = isString; + function isObject(obj) { + return ObjProto.toString.call(obj) === "[object Object]"; + } + _exports.isObject = isObject; + function _prepareAttributeParts(attr) { + if (!attr) { + return []; + } + if (typeof attr === "string") { + return attr.split("."); + } + return [attr]; + } + function getAttrGetter(attribute) { + var parts = _prepareAttributeParts(attribute); + return function attrGetter(item) { + var _item = item; + for (var i = 0; i < parts.length; i++) { + var part = parts[i]; + if (hasOwnProp(_item, part)) { + _item = _item[part]; + } else { + return void 0; + } + } + return _item; + }; + } + _exports.getAttrGetter = getAttrGetter; + function groupBy(obj, val, throwOnUndefined) { + var result = {}; + var iterator = isFunction(val) ? val : getAttrGetter(val); + for (var i = 0; i < obj.length; i++) { + var value = obj[i]; + var key = iterator(value, i); + if (key === void 0 && throwOnUndefined === true) { + throw new TypeError('groupby: attribute "' + val + '" resolved to undefined'); + } + (result[key] || (result[key] = [])).push(value); + } + return result; + } + _exports.groupBy = groupBy; + function toArray(obj) { + return Array.prototype.slice.call(obj); + } + _exports.toArray = toArray; + function without(array) { + var result = []; + if (!array) { + return result; + } + var length = array.length; + var contains = toArray(arguments).slice(1); + var index = -1; + while (++index < length) { + if (indexOf(contains, array[index]) === -1) { + result.push(array[index]); + } + } + return result; + } + _exports.without = without; + function repeat(char_, n) { + var str = ""; + for (var i = 0; i < n; i++) { + str += char_; + } + return str; + } + _exports.repeat = repeat; + function each(obj, func, context) { + if (obj == null) { + return; + } + if (ArrayProto.forEach && obj.forEach === ArrayProto.forEach) { + obj.forEach(func, context); + } else if (obj.length === +obj.length) { + for (var i = 0, l = obj.length; i < l; i++) { + func.call(context, obj[i], i, obj); + } + } + } + _exports.each = each; + function map(obj, func) { + var results = []; + if (obj == null) { + return results; + } + if (ArrayProto.map && obj.map === ArrayProto.map) { + return obj.map(func); + } + for (var i = 0; i < obj.length; i++) { + results[results.length] = func(obj[i], i); + } + if (obj.length === +obj.length) { + results.length = obj.length; + } + return results; + } + _exports.map = map; + function asyncIter(arr, iter, cb) { + var i = -1; + function next() { + i++; + if (i < arr.length) { + iter(arr[i], i, next, cb); + } else { + cb(); + } + } + next(); + } + _exports.asyncIter = asyncIter; + function asyncFor(obj, iter, cb) { + var keys = keys_(obj || {}); + var len = keys.length; + var i = -1; + function next() { + i++; + var k = keys[i]; + if (i < len) { + iter(k, obj[k], i, len, next); + } else { + cb(); + } + } + next(); + } + _exports.asyncFor = asyncFor; + function indexOf(arr, searchElement, fromIndex) { + return Array.prototype.indexOf.call(arr || [], searchElement, fromIndex); + } + _exports.indexOf = indexOf; + function keys_(obj) { + var arr = []; + for (var k in obj) { + if (hasOwnProp(obj, k)) { + arr.push(k); + } + } + return arr; + } + _exports.keys = keys_; + function _entries(obj) { + return keys_(obj).map(function(k) { + return [k, obj[k]]; + }); + } + _exports._entries = _entries; + function _values(obj) { + return keys_(obj).map(function(k) { + return obj[k]; + }); + } + _exports._values = _values; + function extend(obj1, obj2) { + obj1 = obj1 || {}; + keys_(obj2).forEach(function(k) { + obj1[k] = obj2[k]; + }); + return obj1; + } + _exports._assign = _exports.extend = extend; + function inOperator(key, val) { + if (isArray(val) || isString(val)) { + return val.indexOf(key) !== -1; + } else if (isObject(val)) { + return key in val; + } + throw new Error('Cannot use "in" operator to search for "' + key + '" in unexpected types.'); + } + _exports.inOperator = inOperator; +}); + +// ../../node_modules/asap/raw.js +var require_raw = __commonJS((exports2, module2) => { + "use strict"; + var domain; + var hasSetImmediate = typeof setImmediate === "function"; + module2.exports = rawAsap; + function rawAsap(task) { + if (!queue.length) { + requestFlush(); + flushing = true; + } + queue[queue.length] = task; + } + var queue = []; + var flushing = false; + var index = 0; + var capacity = 1024; + function flush() { + while (index < queue.length) { + var currentIndex = index; + index = index + 1; + queue[currentIndex].call(); + if (index > capacity) { + for (var scan = 0, newLength = queue.length - index; scan < newLength; scan++) { + queue[scan] = queue[scan + index]; + } + queue.length -= index; + index = 0; + } + } + queue.length = 0; + index = 0; + flushing = false; + } + rawAsap.requestFlush = requestFlush; + function requestFlush() { + var parentDomain = process.domain; + if (parentDomain) { + if (!domain) { + domain = require("domain"); + } + domain.active = process.domain = null; + } + if (flushing && hasSetImmediate) { + setImmediate(flush); + } else { + process.nextTick(flush); + } + if (parentDomain) { + domain.active = process.domain = parentDomain; + } + } +}); + +// ../../node_modules/asap/asap.js +var require_asap = __commonJS((exports2, module2) => { + "use strict"; + var rawAsap = require_raw(); + var freeTasks = []; + module2.exports = asap; + function asap(task) { + var rawTask; + if (freeTasks.length) { + rawTask = freeTasks.pop(); + } else { + rawTask = new RawTask(); + } + rawTask.task = task; + rawTask.domain = process.domain; + rawAsap(rawTask); + } + function RawTask() { + this.task = null; + this.domain = null; + } + RawTask.prototype.call = function() { + if (this.domain) { + this.domain.enter(); + } + var threw = true; + try { + this.task.call(); + threw = false; + if (this.domain) { + this.domain.exit(); + } + } finally { + if (threw) { + rawAsap.requestFlush(); + } + this.task = null; + this.domain = null; + freeTasks.push(this); + } + }; +}); + +// ../../node_modules/a-sync-waterfall/index.js +var require_a_sync_waterfall = __commonJS((exports2, module2) => { + (function(globals) { + "use strict"; + var executeSync = function() { + var args = Array.prototype.slice.call(arguments); + if (typeof args[0] === "function") { + args[0].apply(null, args.splice(1)); + } + }; + var executeAsync = function(fn) { + if (typeof setImmediate === "function") { + setImmediate(fn); + } else if (typeof process !== "undefined" && process.nextTick) { + process.nextTick(fn); + } else { + setTimeout(fn, 0); + } + }; + var makeIterator = function(tasks) { + var makeCallback = function(index) { + var fn = function() { + if (tasks.length) { + tasks[index].apply(null, arguments); + } + return fn.next(); + }; + fn.next = function() { + return index < tasks.length - 1 ? makeCallback(index + 1) : null; + }; + return fn; + }; + return makeCallback(0); + }; + var _isArray = Array.isArray || function(maybeArray) { + return Object.prototype.toString.call(maybeArray) === "[object Array]"; + }; + var waterfall = function(tasks, callback, forceAsync) { + var nextTick = forceAsync ? executeAsync : executeSync; + callback = callback || function() { + }; + if (!_isArray(tasks)) { + var err = new Error("First argument to waterfall must be an array of functions"); + return callback(err); + } + if (!tasks.length) { + return callback(); + } + var wrapIterator = function(iterator) { + return function(err2) { + if (err2) { + callback.apply(null, arguments); + callback = function() { + }; + } else { + var args = Array.prototype.slice.call(arguments, 1); + var next = iterator.next(); + if (next) { + args.push(wrapIterator(next)); + } else { + args.push(callback); + } + nextTick(function() { + iterator.apply(null, args); + }); + } + }; + }; + wrapIterator(makeIterator(tasks))(); + }; + if (typeof define !== "undefined" && define.amd) { + define([], function() { + return waterfall; + }); + } else if (typeof module2 !== "undefined" && module2.exports) { + module2.exports = waterfall; + } else { + globals.waterfall = waterfall; + } + })(exports2); +}); + +// ../../node_modules/nunjucks/src/lexer.js +var require_lexer = __commonJS((exports2, module2) => { + "use strict"; + var lib2 = require_lib(); + var whitespaceChars = " \n \r\xA0"; + var delimChars = "()[]{}%*-+~/#,:|.<>=!"; + var intChars = "0123456789"; + var BLOCK_START = "{%"; + var BLOCK_END = "%}"; + var VARIABLE_START = "{{"; + var VARIABLE_END = "}}"; + var COMMENT_START = "{#"; + var COMMENT_END = "#}"; + var TOKEN_STRING = "string"; + var TOKEN_WHITESPACE = "whitespace"; + var TOKEN_DATA = "data"; + var TOKEN_BLOCK_START = "block-start"; + var TOKEN_BLOCK_END = "block-end"; + var TOKEN_VARIABLE_START = "variable-start"; + var TOKEN_VARIABLE_END = "variable-end"; + var TOKEN_COMMENT = "comment"; + var TOKEN_LEFT_PAREN = "left-paren"; + var TOKEN_RIGHT_PAREN = "right-paren"; + var TOKEN_LEFT_BRACKET = "left-bracket"; + var TOKEN_RIGHT_BRACKET = "right-bracket"; + var TOKEN_LEFT_CURLY = "left-curly"; + var TOKEN_RIGHT_CURLY = "right-curly"; + var TOKEN_OPERATOR = "operator"; + var TOKEN_COMMA = "comma"; + var TOKEN_COLON = "colon"; + var TOKEN_TILDE = "tilde"; + var TOKEN_PIPE = "pipe"; + var TOKEN_INT = "int"; + var TOKEN_FLOAT = "float"; + var TOKEN_BOOLEAN = "boolean"; + var TOKEN_NONE = "none"; + var TOKEN_SYMBOL = "symbol"; + var TOKEN_SPECIAL = "special"; + var TOKEN_REGEX = "regex"; + function token(type, value, lineno, colno) { + return { + type, + value, + lineno, + colno + }; + } + var Tokenizer = /* @__PURE__ */ function() { + function Tokenizer2(str, opts) { + this.str = str; + this.index = 0; + this.len = str.length; + this.lineno = 0; + this.colno = 0; + this.in_code = false; + opts = opts || {}; + var tags = opts.tags || {}; + this.tags = { + BLOCK_START: tags.blockStart || BLOCK_START, + BLOCK_END: tags.blockEnd || BLOCK_END, + VARIABLE_START: tags.variableStart || VARIABLE_START, + VARIABLE_END: tags.variableEnd || VARIABLE_END, + COMMENT_START: tags.commentStart || COMMENT_START, + COMMENT_END: tags.commentEnd || COMMENT_END + }; + this.trimBlocks = !!opts.trimBlocks; + this.lstripBlocks = !!opts.lstripBlocks; + } + var _proto = Tokenizer2.prototype; + _proto.nextToken = function nextToken() { + var lineno = this.lineno; + var colno = this.colno; + var tok; + if (this.in_code) { + var cur = this.current(); + if (this.isFinished()) { + return null; + } else if (cur === '"' || cur === "'") { + return token(TOKEN_STRING, this._parseString(cur), lineno, colno); + } else if (tok = this._extract(whitespaceChars)) { + return token(TOKEN_WHITESPACE, tok, lineno, colno); + } else if ((tok = this._extractString(this.tags.BLOCK_END)) || (tok = this._extractString("-" + this.tags.BLOCK_END))) { + this.in_code = false; + if (this.trimBlocks) { + cur = this.current(); + if (cur === "\n") { + this.forward(); + } else if (cur === "\r") { + this.forward(); + cur = this.current(); + if (cur === "\n") { + this.forward(); + } else { + this.back(); + } + } + } + return token(TOKEN_BLOCK_END, tok, lineno, colno); + } else if ((tok = this._extractString(this.tags.VARIABLE_END)) || (tok = this._extractString("-" + this.tags.VARIABLE_END))) { + this.in_code = false; + return token(TOKEN_VARIABLE_END, tok, lineno, colno); + } else if (cur === "r" && this.str.charAt(this.index + 1) === "/") { + this.forwardN(2); + var regexBody = ""; + while (!this.isFinished()) { + if (this.current() === "/" && this.previous() !== "\\") { + this.forward(); + break; + } else { + regexBody += this.current(); + this.forward(); + } + } + var POSSIBLE_FLAGS = ["g", "i", "m", "y"]; + var regexFlags = ""; + while (!this.isFinished()) { + var isCurrentAFlag = POSSIBLE_FLAGS.indexOf(this.current()) !== -1; + if (isCurrentAFlag) { + regexFlags += this.current(); + this.forward(); + } else { + break; + } + } + return token(TOKEN_REGEX, { + body: regexBody, + flags: regexFlags + }, lineno, colno); + } else if (delimChars.indexOf(cur) !== -1) { + this.forward(); + var complexOps = ["==", "===", "!=", "!==", "<=", ">=", "//", "**"]; + var curComplex = cur + this.current(); + var type; + if (lib2.indexOf(complexOps, curComplex) !== -1) { + this.forward(); + cur = curComplex; + if (lib2.indexOf(complexOps, curComplex + this.current()) !== -1) { + cur = curComplex + this.current(); + this.forward(); + } + } + switch (cur) { + case "(": + type = TOKEN_LEFT_PAREN; + break; + case ")": + type = TOKEN_RIGHT_PAREN; + break; + case "[": + type = TOKEN_LEFT_BRACKET; + break; + case "]": + type = TOKEN_RIGHT_BRACKET; + break; + case "{": + type = TOKEN_LEFT_CURLY; + break; + case "}": + type = TOKEN_RIGHT_CURLY; + break; + case ",": + type = TOKEN_COMMA; + break; + case ":": + type = TOKEN_COLON; + break; + case "~": + type = TOKEN_TILDE; + break; + case "|": + type = TOKEN_PIPE; + break; + default: + type = TOKEN_OPERATOR; + } + return token(type, cur, lineno, colno); + } else { + tok = this._extractUntil(whitespaceChars + delimChars); + if (tok.match(/^[-+]?[0-9]+$/)) { + if (this.current() === ".") { + this.forward(); + var dec = this._extract(intChars); + return token(TOKEN_FLOAT, tok + "." + dec, lineno, colno); + } else { + return token(TOKEN_INT, tok, lineno, colno); + } + } else if (tok.match(/^(true|false)$/)) { + return token(TOKEN_BOOLEAN, tok, lineno, colno); + } else if (tok === "none") { + return token(TOKEN_NONE, tok, lineno, colno); + } else if (tok === "null") { + return token(TOKEN_NONE, tok, lineno, colno); + } else if (tok) { + return token(TOKEN_SYMBOL, tok, lineno, colno); + } else { + throw new Error("Unexpected value while parsing: " + tok); + } + } + } else { + var beginChars = this.tags.BLOCK_START.charAt(0) + this.tags.VARIABLE_START.charAt(0) + this.tags.COMMENT_START.charAt(0) + this.tags.COMMENT_END.charAt(0); + if (this.isFinished()) { + return null; + } else if ((tok = this._extractString(this.tags.BLOCK_START + "-")) || (tok = this._extractString(this.tags.BLOCK_START))) { + this.in_code = true; + return token(TOKEN_BLOCK_START, tok, lineno, colno); + } else if ((tok = this._extractString(this.tags.VARIABLE_START + "-")) || (tok = this._extractString(this.tags.VARIABLE_START))) { + this.in_code = true; + return token(TOKEN_VARIABLE_START, tok, lineno, colno); + } else { + tok = ""; + var data; + var inComment = false; + if (this._matches(this.tags.COMMENT_START)) { + inComment = true; + tok = this._extractString(this.tags.COMMENT_START); + } + while ((data = this._extractUntil(beginChars)) !== null) { + tok += data; + if ((this._matches(this.tags.BLOCK_START) || this._matches(this.tags.VARIABLE_START) || this._matches(this.tags.COMMENT_START)) && !inComment) { + if (this.lstripBlocks && this._matches(this.tags.BLOCK_START) && this.colno > 0 && this.colno <= tok.length) { + var lastLine = tok.slice(-this.colno); + if (/^\s+$/.test(lastLine)) { + tok = tok.slice(0, -this.colno); + if (!tok.length) { + return this.nextToken(); + } + } + } + break; + } else if (this._matches(this.tags.COMMENT_END)) { + if (!inComment) { + throw new Error("unexpected end of comment"); + } + tok += this._extractString(this.tags.COMMENT_END); + break; + } else { + tok += this.current(); + this.forward(); + } + } + if (data === null && inComment) { + throw new Error("expected end of comment, got end of file"); + } + return token(inComment ? TOKEN_COMMENT : TOKEN_DATA, tok, lineno, colno); + } + } + }; + _proto._parseString = function _parseString(delimiter) { + this.forward(); + var str = ""; + while (!this.isFinished() && this.current() !== delimiter) { + var cur = this.current(); + if (cur === "\\") { + this.forward(); + switch (this.current()) { + case "n": + str += "\n"; + break; + case "t": + str += " "; + break; + case "r": + str += "\r"; + break; + default: + str += this.current(); + } + this.forward(); + } else { + str += cur; + this.forward(); + } + } + this.forward(); + return str; + }; + _proto._matches = function _matches(str) { + if (this.index + str.length > this.len) { + return null; + } + var m = this.str.slice(this.index, this.index + str.length); + return m === str; + }; + _proto._extractString = function _extractString(str) { + if (this._matches(str)) { + this.forwardN(str.length); + return str; + } + return null; + }; + _proto._extractUntil = function _extractUntil(charString) { + return this._extractMatching(true, charString || ""); + }; + _proto._extract = function _extract(charString) { + return this._extractMatching(false, charString); + }; + _proto._extractMatching = function _extractMatching(breakOnMatch, charString) { + if (this.isFinished()) { + return null; + } + var first = charString.indexOf(this.current()); + if (breakOnMatch && first === -1 || !breakOnMatch && first !== -1) { + var t = this.current(); + this.forward(); + var idx = charString.indexOf(this.current()); + while ((breakOnMatch && idx === -1 || !breakOnMatch && idx !== -1) && !this.isFinished()) { + t += this.current(); + this.forward(); + idx = charString.indexOf(this.current()); + } + return t; + } + return ""; + }; + _proto._extractRegex = function _extractRegex(regex) { + var matches = this.currentStr().match(regex); + if (!matches) { + return null; + } + this.forwardN(matches[0].length); + return matches; + }; + _proto.isFinished = function isFinished() { + return this.index >= this.len; + }; + _proto.forwardN = function forwardN(n) { + for (var i = 0; i < n; i++) { + this.forward(); + } + }; + _proto.forward = function forward() { + this.index++; + if (this.previous() === "\n") { + this.lineno++; + this.colno = 0; + } else { + this.colno++; + } + }; + _proto.backN = function backN(n) { + for (var i = 0; i < n; i++) { + this.back(); + } + }; + _proto.back = function back() { + this.index--; + if (this.current() === "\n") { + this.lineno--; + var idx = this.src.lastIndexOf("\n", this.index - 1); + if (idx === -1) { + this.colno = this.index; + } else { + this.colno = this.index - idx; + } + } else { + this.colno--; + } + }; + _proto.current = function current() { + if (!this.isFinished()) { + return this.str.charAt(this.index); + } + return ""; + }; + _proto.currentStr = function currentStr() { + if (!this.isFinished()) { + return this.str.substr(this.index); + } + return ""; + }; + _proto.previous = function previous() { + return this.str.charAt(this.index - 1); + }; + return Tokenizer2; + }(); + module2.exports = { + lex: function lex(src, opts) { + return new Tokenizer(src, opts); + }, + TOKEN_STRING, + TOKEN_WHITESPACE, + TOKEN_DATA, + TOKEN_BLOCK_START, + TOKEN_BLOCK_END, + TOKEN_VARIABLE_START, + TOKEN_VARIABLE_END, + TOKEN_COMMENT, + TOKEN_LEFT_PAREN, + TOKEN_RIGHT_PAREN, + TOKEN_LEFT_BRACKET, + TOKEN_RIGHT_BRACKET, + TOKEN_LEFT_CURLY, + TOKEN_RIGHT_CURLY, + TOKEN_OPERATOR, + TOKEN_COMMA, + TOKEN_COLON, + TOKEN_TILDE, + TOKEN_PIPE, + TOKEN_INT, + TOKEN_FLOAT, + TOKEN_BOOLEAN, + TOKEN_NONE, + TOKEN_SYMBOL, + TOKEN_SPECIAL, + TOKEN_REGEX + }; +}); + +// ../../node_modules/nunjucks/src/object.js +var require_object = __commonJS((exports2, module2) => { + "use strict"; + function _defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) + descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + function _createClass(Constructor, protoProps, staticProps) { + if (protoProps) + _defineProperties(Constructor.prototype, protoProps); + if (staticProps) + _defineProperties(Constructor, staticProps); + return Constructor; + } + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + _setPrototypeOf(subClass, superClass); + } + function _setPrototypeOf(o, p) { + _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf2(o2, p2) { + o2.__proto__ = p2; + return o2; + }; + return _setPrototypeOf(o, p); + } + var EventEmitter = require("events"); + var lib2 = require_lib(); + function parentWrap(parent, prop) { + if (typeof parent !== "function" || typeof prop !== "function") { + return prop; + } + return function wrap() { + var tmp = this.parent; + this.parent = parent; + var res = prop.apply(this, arguments); + this.parent = tmp; + return res; + }; + } + function extendClass(cls, name, props) { + props = props || {}; + lib2.keys(props).forEach(function(k) { + props[k] = parentWrap(cls.prototype[k], props[k]); + }); + var subclass = /* @__PURE__ */ function(_cls) { + _inheritsLoose(subclass2, _cls); + function subclass2() { + return _cls.apply(this, arguments) || this; + } + _createClass(subclass2, [{ + key: "typename", + get: function get() { + return name; + } + }]); + return subclass2; + }(cls); + lib2._assign(subclass.prototype, props); + return subclass; + } + var Obj = /* @__PURE__ */ function() { + function Obj2() { + this.init.apply(this, arguments); + } + var _proto = Obj2.prototype; + _proto.init = function init() { + }; + Obj2.extend = function extend(name, props) { + if (typeof name === "object") { + props = name; + name = "anonymous"; + } + return extendClass(this, name, props); + }; + _createClass(Obj2, [{ + key: "typename", + get: function get() { + return this.constructor.name; + } + }]); + return Obj2; + }(); + var EmitterObj = /* @__PURE__ */ function(_EventEmitter) { + _inheritsLoose(EmitterObj2, _EventEmitter); + function EmitterObj2() { + var _this2; + var _this; + _this = _EventEmitter.call(this) || this; + (_this2 = _this).init.apply(_this2, arguments); + return _this; + } + var _proto2 = EmitterObj2.prototype; + _proto2.init = function init() { + }; + EmitterObj2.extend = function extend(name, props) { + if (typeof name === "object") { + props = name; + name = "anonymous"; + } + return extendClass(this, name, props); + }; + _createClass(EmitterObj2, [{ + key: "typename", + get: function get() { + return this.constructor.name; + } + }]); + return EmitterObj2; + }(EventEmitter); + module2.exports = { + Obj, + EmitterObj + }; +}); + +// ../../node_modules/nunjucks/src/nodes.js +var require_nodes = __commonJS((exports2, module2) => { + "use strict"; + function _defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) + descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + function _createClass(Constructor, protoProps, staticProps) { + if (protoProps) + _defineProperties(Constructor.prototype, protoProps); + if (staticProps) + _defineProperties(Constructor, staticProps); + return Constructor; + } + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + _setPrototypeOf(subClass, superClass); + } + function _setPrototypeOf(o, p) { + _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf2(o2, p2) { + o2.__proto__ = p2; + return o2; + }; + return _setPrototypeOf(o, p); + } + var _require2 = require_object(); + var Obj = _require2.Obj; + function traverseAndCheck(obj, type, results) { + if (obj instanceof type) { + results.push(obj); + } + if (obj instanceof Node) { + obj.findAll(type, results); + } + } + var Node = /* @__PURE__ */ function(_Obj) { + _inheritsLoose(Node2, _Obj); + function Node2() { + return _Obj.apply(this, arguments) || this; + } + var _proto = Node2.prototype; + _proto.init = function init(lineno, colno) { + var _arguments = arguments, _this = this; + for (var _len = arguments.length, args = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) { + args[_key - 2] = arguments[_key]; + } + this.lineno = lineno; + this.colno = colno; + this.fields.forEach(function(field, i) { + var val = _arguments[i + 2]; + if (val === void 0) { + val = null; + } + _this[field] = val; + }); + }; + _proto.findAll = function findAll(type, results) { + var _this2 = this; + results = results || []; + if (this instanceof NodeList) { + this.children.forEach(function(child) { + return traverseAndCheck(child, type, results); + }); + } else { + this.fields.forEach(function(field) { + return traverseAndCheck(_this2[field], type, results); + }); + } + return results; + }; + _proto.iterFields = function iterFields(func) { + var _this3 = this; + this.fields.forEach(function(field) { + func(_this3[field], field); + }); + }; + return Node2; + }(Obj); + var Value = /* @__PURE__ */ function(_Node) { + _inheritsLoose(Value2, _Node); + function Value2() { + return _Node.apply(this, arguments) || this; + } + _createClass(Value2, [{ + key: "typename", + get: function get() { + return "Value"; + } + }, { + key: "fields", + get: function get() { + return ["value"]; + } + }]); + return Value2; + }(Node); + var NodeList = /* @__PURE__ */ function(_Node2) { + _inheritsLoose(NodeList2, _Node2); + function NodeList2() { + return _Node2.apply(this, arguments) || this; + } + var _proto2 = NodeList2.prototype; + _proto2.init = function init(lineno, colno, nodes2) { + _Node2.prototype.init.call(this, lineno, colno, nodes2 || []); + }; + _proto2.addChild = function addChild(node) { + this.children.push(node); + }; + _createClass(NodeList2, [{ + key: "typename", + get: function get() { + return "NodeList"; + } + }, { + key: "fields", + get: function get() { + return ["children"]; + } + }]); + return NodeList2; + }(Node); + var Root = NodeList.extend("Root"); + var Literal = Value.extend("Literal"); + var Symbol2 = Value.extend("Symbol"); + var Group = NodeList.extend("Group"); + var ArrayNode = NodeList.extend("Array"); + var Pair = Node.extend("Pair", { + fields: ["key", "value"] + }); + var Dict = NodeList.extend("Dict"); + var LookupVal = Node.extend("LookupVal", { + fields: ["target", "val"] + }); + var If = Node.extend("If", { + fields: ["cond", "body", "else_"] + }); + var IfAsync = If.extend("IfAsync"); + var InlineIf = Node.extend("InlineIf", { + fields: ["cond", "body", "else_"] + }); + var For = Node.extend("For", { + fields: ["arr", "name", "body", "else_"] + }); + var AsyncEach = For.extend("AsyncEach"); + var AsyncAll = For.extend("AsyncAll"); + var Macro = Node.extend("Macro", { + fields: ["name", "args", "body"] + }); + var Caller = Macro.extend("Caller"); + var Import = Node.extend("Import", { + fields: ["template", "target", "withContext"] + }); + var FromImport = /* @__PURE__ */ function(_Node3) { + _inheritsLoose(FromImport2, _Node3); + function FromImport2() { + return _Node3.apply(this, arguments) || this; + } + var _proto3 = FromImport2.prototype; + _proto3.init = function init(lineno, colno, template, names, withContext) { + _Node3.prototype.init.call(this, lineno, colno, template, names || new NodeList(), withContext); + }; + _createClass(FromImport2, [{ + key: "typename", + get: function get() { + return "FromImport"; + } + }, { + key: "fields", + get: function get() { + return ["template", "names", "withContext"]; + } + }]); + return FromImport2; + }(Node); + var FunCall = Node.extend("FunCall", { + fields: ["name", "args"] + }); + var Filter = FunCall.extend("Filter"); + var FilterAsync = Filter.extend("FilterAsync", { + fields: ["name", "args", "symbol"] + }); + var KeywordArgs = Dict.extend("KeywordArgs"); + var Block = Node.extend("Block", { + fields: ["name", "body"] + }); + var Super = Node.extend("Super", { + fields: ["blockName", "symbol"] + }); + var TemplateRef = Node.extend("TemplateRef", { + fields: ["template"] + }); + var Extends = TemplateRef.extend("Extends"); + var Include = Node.extend("Include", { + fields: ["template", "ignoreMissing"] + }); + var Set2 = Node.extend("Set", { + fields: ["targets", "value"] + }); + var Switch = Node.extend("Switch", { + fields: ["expr", "cases", "default"] + }); + var Case = Node.extend("Case", { + fields: ["cond", "body"] + }); + var Output = NodeList.extend("Output"); + var Capture = Node.extend("Capture", { + fields: ["body"] + }); + var TemplateData = Literal.extend("TemplateData"); + var UnaryOp = Node.extend("UnaryOp", { + fields: ["target"] + }); + var BinOp = Node.extend("BinOp", { + fields: ["left", "right"] + }); + var In = BinOp.extend("In"); + var Is = BinOp.extend("Is"); + var Or = BinOp.extend("Or"); + var And = BinOp.extend("And"); + var Not = UnaryOp.extend("Not"); + var Add = BinOp.extend("Add"); + var Concat = BinOp.extend("Concat"); + var Sub = BinOp.extend("Sub"); + var Mul = BinOp.extend("Mul"); + var Div = BinOp.extend("Div"); + var FloorDiv = BinOp.extend("FloorDiv"); + var Mod = BinOp.extend("Mod"); + var Pow = BinOp.extend("Pow"); + var Neg = UnaryOp.extend("Neg"); + var Pos = UnaryOp.extend("Pos"); + var Compare = Node.extend("Compare", { + fields: ["expr", "ops"] + }); + var CompareOperand = Node.extend("CompareOperand", { + fields: ["expr", "type"] + }); + var CallExtension = Node.extend("CallExtension", { + init: function init(ext, prop, args, contentArgs) { + this.parent(); + this.extName = ext.__name || ext; + this.prop = prop; + this.args = args || new NodeList(); + this.contentArgs = contentArgs || []; + this.autoescape = ext.autoescape; + }, + fields: ["extName", "prop", "args", "contentArgs"] + }); + var CallExtensionAsync = CallExtension.extend("CallExtensionAsync"); + function print(str, indent, inline) { + var lines = str.split("\n"); + lines.forEach(function(line, i) { + if (line && (inline && i > 0 || !inline)) { + process.stdout.write(" ".repeat(indent)); + } + var nl = i === lines.length - 1 ? "" : "\n"; + process.stdout.write("" + line + nl); + }); + } + function printNodes(node, indent) { + indent = indent || 0; + print(node.typename + ": ", indent); + if (node instanceof NodeList) { + print("\n"); + node.children.forEach(function(n) { + printNodes(n, indent + 2); + }); + } else if (node instanceof CallExtension) { + print(node.extName + "." + node.prop + "\n"); + if (node.args) { + printNodes(node.args, indent + 2); + } + if (node.contentArgs) { + node.contentArgs.forEach(function(n) { + printNodes(n, indent + 2); + }); + } + } else { + var nodes2 = []; + var props = null; + node.iterFields(function(val, fieldName) { + if (val instanceof Node) { + nodes2.push([fieldName, val]); + } else { + props = props || {}; + props[fieldName] = val; + } + }); + if (props) { + print(JSON.stringify(props, null, 2) + "\n", null, true); + } else { + print("\n"); + } + nodes2.forEach(function(_ref) { + var fieldName = _ref[0], n = _ref[1]; + print("[" + fieldName + "] =>", indent + 2); + printNodes(n, indent + 4); + }); + } + } + module2.exports = { + Node, + Root, + NodeList, + Value, + Literal, + Symbol: Symbol2, + Group, + Array: ArrayNode, + Pair, + Dict, + Output, + Capture, + TemplateData, + If, + IfAsync, + InlineIf, + For, + AsyncEach, + AsyncAll, + Macro, + Caller, + Import, + FromImport, + FunCall, + Filter, + FilterAsync, + KeywordArgs, + Block, + Super, + Extends, + Include, + Set: Set2, + Switch, + Case, + LookupVal, + BinOp, + In, + Is, + Or, + And, + Not, + Add, + Concat, + Sub, + Mul, + Div, + FloorDiv, + Mod, + Pow, + Neg, + Pos, + Compare, + CompareOperand, + CallExtension, + CallExtensionAsync, + printNodes + }; +}); + +// ../../node_modules/nunjucks/src/parser.js +var require_parser = __commonJS((exports2, module2) => { + "use strict"; + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + _setPrototypeOf(subClass, superClass); + } + function _setPrototypeOf(o, p) { + _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf2(o2, p2) { + o2.__proto__ = p2; + return o2; + }; + return _setPrototypeOf(o, p); + } + var lexer2 = require_lexer(); + var nodes2 = require_nodes(); + var Obj = require_object().Obj; + var lib2 = require_lib(); + var Parser = /* @__PURE__ */ function(_Obj) { + _inheritsLoose(Parser2, _Obj); + function Parser2() { + return _Obj.apply(this, arguments) || this; + } + var _proto = Parser2.prototype; + _proto.init = function init(tokens) { + this.tokens = tokens; + this.peeked = null; + this.breakOnBlocks = null; + this.dropLeadingWhitespace = false; + this.extensions = []; + }; + _proto.nextToken = function nextToken(withWhitespace) { + var tok; + if (this.peeked) { + if (!withWhitespace && this.peeked.type === lexer2.TOKEN_WHITESPACE) { + this.peeked = null; + } else { + tok = this.peeked; + this.peeked = null; + return tok; + } + } + tok = this.tokens.nextToken(); + if (!withWhitespace) { + while (tok && tok.type === lexer2.TOKEN_WHITESPACE) { + tok = this.tokens.nextToken(); + } + } + return tok; + }; + _proto.peekToken = function peekToken() { + this.peeked = this.peeked || this.nextToken(); + return this.peeked; + }; + _proto.pushToken = function pushToken(tok) { + if (this.peeked) { + throw new Error("pushToken: can only push one token on between reads"); + } + this.peeked = tok; + }; + _proto.error = function error(msg, lineno, colno) { + if (lineno === void 0 || colno === void 0) { + var tok = this.peekToken() || {}; + lineno = tok.lineno; + colno = tok.colno; + } + if (lineno !== void 0) { + lineno += 1; + } + if (colno !== void 0) { + colno += 1; + } + return new lib2.TemplateError(msg, lineno, colno); + }; + _proto.fail = function fail(msg, lineno, colno) { + throw this.error(msg, lineno, colno); + }; + _proto.skip = function skip(type) { + var tok = this.nextToken(); + if (!tok || tok.type !== type) { + this.pushToken(tok); + return false; + } + return true; + }; + _proto.expect = function expect(type) { + var tok = this.nextToken(); + if (tok.type !== type) { + this.fail("expected " + type + ", got " + tok.type, tok.lineno, tok.colno); + } + return tok; + }; + _proto.skipValue = function skipValue(type, val) { + var tok = this.nextToken(); + if (!tok || tok.type !== type || tok.value !== val) { + this.pushToken(tok); + return false; + } + return true; + }; + _proto.skipSymbol = function skipSymbol(val) { + return this.skipValue(lexer2.TOKEN_SYMBOL, val); + }; + _proto.advanceAfterBlockEnd = function advanceAfterBlockEnd(name) { + var tok; + if (!name) { + tok = this.peekToken(); + if (!tok) { + this.fail("unexpected end of file"); + } + if (tok.type !== lexer2.TOKEN_SYMBOL) { + this.fail("advanceAfterBlockEnd: expected symbol token or explicit name to be passed"); + } + name = this.nextToken().value; + } + tok = this.nextToken(); + if (tok && tok.type === lexer2.TOKEN_BLOCK_END) { + if (tok.value.charAt(0) === "-") { + this.dropLeadingWhitespace = true; + } + } else { + this.fail("expected block end in " + name + " statement"); + } + return tok; + }; + _proto.advanceAfterVariableEnd = function advanceAfterVariableEnd() { + var tok = this.nextToken(); + if (tok && tok.type === lexer2.TOKEN_VARIABLE_END) { + this.dropLeadingWhitespace = tok.value.charAt(tok.value.length - this.tokens.tags.VARIABLE_END.length - 1) === "-"; + } else { + this.pushToken(tok); + this.fail("expected variable end"); + } + }; + _proto.parseFor = function parseFor() { + var forTok = this.peekToken(); + var node; + var endBlock; + if (this.skipSymbol("for")) { + node = new nodes2.For(forTok.lineno, forTok.colno); + endBlock = "endfor"; + } else if (this.skipSymbol("asyncEach")) { + node = new nodes2.AsyncEach(forTok.lineno, forTok.colno); + endBlock = "endeach"; + } else if (this.skipSymbol("asyncAll")) { + node = new nodes2.AsyncAll(forTok.lineno, forTok.colno); + endBlock = "endall"; + } else { + this.fail("parseFor: expected for{Async}", forTok.lineno, forTok.colno); + } + node.name = this.parsePrimary(); + if (!(node.name instanceof nodes2.Symbol)) { + this.fail("parseFor: variable name expected for loop"); + } + var type = this.peekToken().type; + if (type === lexer2.TOKEN_COMMA) { + var key = node.name; + node.name = new nodes2.Array(key.lineno, key.colno); + node.name.addChild(key); + while (this.skip(lexer2.TOKEN_COMMA)) { + var prim = this.parsePrimary(); + node.name.addChild(prim); + } + } + if (!this.skipSymbol("in")) { + this.fail('parseFor: expected "in" keyword for loop', forTok.lineno, forTok.colno); + } + node.arr = this.parseExpression(); + this.advanceAfterBlockEnd(forTok.value); + node.body = this.parseUntilBlocks(endBlock, "else"); + if (this.skipSymbol("else")) { + this.advanceAfterBlockEnd("else"); + node.else_ = this.parseUntilBlocks(endBlock); + } + this.advanceAfterBlockEnd(); + return node; + }; + _proto.parseMacro = function parseMacro() { + var macroTok = this.peekToken(); + if (!this.skipSymbol("macro")) { + this.fail("expected macro"); + } + var name = this.parsePrimary(true); + var args = this.parseSignature(); + var node = new nodes2.Macro(macroTok.lineno, macroTok.colno, name, args); + this.advanceAfterBlockEnd(macroTok.value); + node.body = this.parseUntilBlocks("endmacro"); + this.advanceAfterBlockEnd(); + return node; + }; + _proto.parseCall = function parseCall() { + var callTok = this.peekToken(); + if (!this.skipSymbol("call")) { + this.fail("expected call"); + } + var callerArgs = this.parseSignature(true) || new nodes2.NodeList(); + var macroCall = this.parsePrimary(); + this.advanceAfterBlockEnd(callTok.value); + var body = this.parseUntilBlocks("endcall"); + this.advanceAfterBlockEnd(); + var callerName = new nodes2.Symbol(callTok.lineno, callTok.colno, "caller"); + var callerNode = new nodes2.Caller(callTok.lineno, callTok.colno, callerName, callerArgs, body); + var args = macroCall.args.children; + if (!(args[args.length - 1] instanceof nodes2.KeywordArgs)) { + args.push(new nodes2.KeywordArgs()); + } + var kwargs = args[args.length - 1]; + kwargs.addChild(new nodes2.Pair(callTok.lineno, callTok.colno, callerName, callerNode)); + return new nodes2.Output(callTok.lineno, callTok.colno, [macroCall]); + }; + _proto.parseWithContext = function parseWithContext() { + var tok = this.peekToken(); + var withContext = null; + if (this.skipSymbol("with")) { + withContext = true; + } else if (this.skipSymbol("without")) { + withContext = false; + } + if (withContext !== null) { + if (!this.skipSymbol("context")) { + this.fail("parseFrom: expected context after with/without", tok.lineno, tok.colno); + } + } + return withContext; + }; + _proto.parseImport = function parseImport() { + var importTok = this.peekToken(); + if (!this.skipSymbol("import")) { + this.fail("parseImport: expected import", importTok.lineno, importTok.colno); + } + var template = this.parseExpression(); + if (!this.skipSymbol("as")) { + this.fail('parseImport: expected "as" keyword', importTok.lineno, importTok.colno); + } + var target = this.parseExpression(); + var withContext = this.parseWithContext(); + var node = new nodes2.Import(importTok.lineno, importTok.colno, template, target, withContext); + this.advanceAfterBlockEnd(importTok.value); + return node; + }; + _proto.parseFrom = function parseFrom() { + var fromTok = this.peekToken(); + if (!this.skipSymbol("from")) { + this.fail("parseFrom: expected from"); + } + var template = this.parseExpression(); + if (!this.skipSymbol("import")) { + this.fail("parseFrom: expected import", fromTok.lineno, fromTok.colno); + } + var names = new nodes2.NodeList(); + var withContext; + while (1) { + var nextTok = this.peekToken(); + if (nextTok.type === lexer2.TOKEN_BLOCK_END) { + if (!names.children.length) { + this.fail("parseFrom: Expected at least one import name", fromTok.lineno, fromTok.colno); + } + if (nextTok.value.charAt(0) === "-") { + this.dropLeadingWhitespace = true; + } + this.nextToken(); + break; + } + if (names.children.length > 0 && !this.skip(lexer2.TOKEN_COMMA)) { + this.fail("parseFrom: expected comma", fromTok.lineno, fromTok.colno); + } + var name = this.parsePrimary(); + if (name.value.charAt(0) === "_") { + this.fail("parseFrom: names starting with an underscore cannot be imported", name.lineno, name.colno); + } + if (this.skipSymbol("as")) { + var alias = this.parsePrimary(); + names.addChild(new nodes2.Pair(name.lineno, name.colno, name, alias)); + } else { + names.addChild(name); + } + withContext = this.parseWithContext(); + } + return new nodes2.FromImport(fromTok.lineno, fromTok.colno, template, names, withContext); + }; + _proto.parseBlock = function parseBlock() { + var tag = this.peekToken(); + if (!this.skipSymbol("block")) { + this.fail("parseBlock: expected block", tag.lineno, tag.colno); + } + var node = new nodes2.Block(tag.lineno, tag.colno); + node.name = this.parsePrimary(); + if (!(node.name instanceof nodes2.Symbol)) { + this.fail("parseBlock: variable name expected", tag.lineno, tag.colno); + } + this.advanceAfterBlockEnd(tag.value); + node.body = this.parseUntilBlocks("endblock"); + this.skipSymbol("endblock"); + this.skipSymbol(node.name.value); + var tok = this.peekToken(); + if (!tok) { + this.fail("parseBlock: expected endblock, got end of file"); + } + this.advanceAfterBlockEnd(tok.value); + return node; + }; + _proto.parseExtends = function parseExtends() { + var tagName = "extends"; + var tag = this.peekToken(); + if (!this.skipSymbol(tagName)) { + this.fail("parseTemplateRef: expected " + tagName); + } + var node = new nodes2.Extends(tag.lineno, tag.colno); + node.template = this.parseExpression(); + this.advanceAfterBlockEnd(tag.value); + return node; + }; + _proto.parseInclude = function parseInclude() { + var tagName = "include"; + var tag = this.peekToken(); + if (!this.skipSymbol(tagName)) { + this.fail("parseInclude: expected " + tagName); + } + var node = new nodes2.Include(tag.lineno, tag.colno); + node.template = this.parseExpression(); + if (this.skipSymbol("ignore") && this.skipSymbol("missing")) { + node.ignoreMissing = true; + } + this.advanceAfterBlockEnd(tag.value); + return node; + }; + _proto.parseIf = function parseIf() { + var tag = this.peekToken(); + var node; + if (this.skipSymbol("if") || this.skipSymbol("elif") || this.skipSymbol("elseif")) { + node = new nodes2.If(tag.lineno, tag.colno); + } else if (this.skipSymbol("ifAsync")) { + node = new nodes2.IfAsync(tag.lineno, tag.colno); + } else { + this.fail("parseIf: expected if, elif, or elseif", tag.lineno, tag.colno); + } + node.cond = this.parseExpression(); + this.advanceAfterBlockEnd(tag.value); + node.body = this.parseUntilBlocks("elif", "elseif", "else", "endif"); + var tok = this.peekToken(); + switch (tok && tok.value) { + case "elseif": + case "elif": + node.else_ = this.parseIf(); + break; + case "else": + this.advanceAfterBlockEnd(); + node.else_ = this.parseUntilBlocks("endif"); + this.advanceAfterBlockEnd(); + break; + case "endif": + node.else_ = null; + this.advanceAfterBlockEnd(); + break; + default: + this.fail("parseIf: expected elif, else, or endif, got end of file"); + } + return node; + }; + _proto.parseSet = function parseSet() { + var tag = this.peekToken(); + if (!this.skipSymbol("set")) { + this.fail("parseSet: expected set", tag.lineno, tag.colno); + } + var node = new nodes2.Set(tag.lineno, tag.colno, []); + var target; + while (target = this.parsePrimary()) { + node.targets.push(target); + if (!this.skip(lexer2.TOKEN_COMMA)) { + break; + } + } + if (!this.skipValue(lexer2.TOKEN_OPERATOR, "=")) { + if (!this.skip(lexer2.TOKEN_BLOCK_END)) { + this.fail("parseSet: expected = or block end in set tag", tag.lineno, tag.colno); + } else { + node.body = new nodes2.Capture(tag.lineno, tag.colno, this.parseUntilBlocks("endset")); + node.value = null; + this.advanceAfterBlockEnd(); + } + } else { + node.value = this.parseExpression(); + this.advanceAfterBlockEnd(tag.value); + } + return node; + }; + _proto.parseSwitch = function parseSwitch() { + var switchStart = "switch"; + var switchEnd = "endswitch"; + var caseStart = "case"; + var caseDefault = "default"; + var tag = this.peekToken(); + if (!this.skipSymbol(switchStart) && !this.skipSymbol(caseStart) && !this.skipSymbol(caseDefault)) { + this.fail('parseSwitch: expected "switch," "case" or "default"', tag.lineno, tag.colno); + } + var expr = this.parseExpression(); + this.advanceAfterBlockEnd(switchStart); + this.parseUntilBlocks(caseStart, caseDefault, switchEnd); + var tok = this.peekToken(); + var cases = []; + var defaultCase; + do { + this.skipSymbol(caseStart); + var cond = this.parseExpression(); + this.advanceAfterBlockEnd(switchStart); + var body = this.parseUntilBlocks(caseStart, caseDefault, switchEnd); + cases.push(new nodes2.Case(tok.line, tok.col, cond, body)); + tok = this.peekToken(); + } while (tok && tok.value === caseStart); + switch (tok.value) { + case caseDefault: + this.advanceAfterBlockEnd(); + defaultCase = this.parseUntilBlocks(switchEnd); + this.advanceAfterBlockEnd(); + break; + case switchEnd: + this.advanceAfterBlockEnd(); + break; + default: + this.fail('parseSwitch: expected "case," "default" or "endswitch," got EOF.'); + } + return new nodes2.Switch(tag.lineno, tag.colno, expr, cases, defaultCase); + }; + _proto.parseStatement = function parseStatement() { + var tok = this.peekToken(); + var node; + if (tok.type !== lexer2.TOKEN_SYMBOL) { + this.fail("tag name expected", tok.lineno, tok.colno); + } + if (this.breakOnBlocks && lib2.indexOf(this.breakOnBlocks, tok.value) !== -1) { + return null; + } + switch (tok.value) { + case "raw": + return this.parseRaw(); + case "verbatim": + return this.parseRaw("verbatim"); + case "if": + case "ifAsync": + return this.parseIf(); + case "for": + case "asyncEach": + case "asyncAll": + return this.parseFor(); + case "block": + return this.parseBlock(); + case "extends": + return this.parseExtends(); + case "include": + return this.parseInclude(); + case "set": + return this.parseSet(); + case "macro": + return this.parseMacro(); + case "call": + return this.parseCall(); + case "import": + return this.parseImport(); + case "from": + return this.parseFrom(); + case "filter": + return this.parseFilterStatement(); + case "switch": + return this.parseSwitch(); + default: + if (this.extensions.length) { + for (var i = 0; i < this.extensions.length; i++) { + var ext = this.extensions[i]; + if (lib2.indexOf(ext.tags || [], tok.value) !== -1) { + return ext.parse(this, nodes2, lexer2); + } + } + } + this.fail("unknown block tag: " + tok.value, tok.lineno, tok.colno); + } + return node; + }; + _proto.parseRaw = function parseRaw(tagName) { + tagName = tagName || "raw"; + var endTagName = "end" + tagName; + var rawBlockRegex = new RegExp("([\\s\\S]*?){%\\s*(" + tagName + "|" + endTagName + ")\\s*(?=%})%}"); + var rawLevel = 1; + var str = ""; + var matches = null; + var begun = this.advanceAfterBlockEnd(); + while ((matches = this.tokens._extractRegex(rawBlockRegex)) && rawLevel > 0) { + var all = matches[0]; + var pre = matches[1]; + var blockName = matches[2]; + if (blockName === tagName) { + rawLevel += 1; + } else if (blockName === endTagName) { + rawLevel -= 1; + } + if (rawLevel === 0) { + str += pre; + this.tokens.backN(all.length - pre.length); + } else { + str += all; + } + } + return new nodes2.Output(begun.lineno, begun.colno, [new nodes2.TemplateData(begun.lineno, begun.colno, str)]); + }; + _proto.parsePostfix = function parsePostfix(node) { + var lookup; + var tok = this.peekToken(); + while (tok) { + if (tok.type === lexer2.TOKEN_LEFT_PAREN) { + node = new nodes2.FunCall(tok.lineno, tok.colno, node, this.parseSignature()); + } else if (tok.type === lexer2.TOKEN_LEFT_BRACKET) { + lookup = this.parseAggregate(); + if (lookup.children.length > 1) { + this.fail("invalid index"); + } + node = new nodes2.LookupVal(tok.lineno, tok.colno, node, lookup.children[0]); + } else if (tok.type === lexer2.TOKEN_OPERATOR && tok.value === ".") { + this.nextToken(); + var val = this.nextToken(); + if (val.type !== lexer2.TOKEN_SYMBOL) { + this.fail("expected name as lookup value, got " + val.value, val.lineno, val.colno); + } + lookup = new nodes2.Literal(val.lineno, val.colno, val.value); + node = new nodes2.LookupVal(tok.lineno, tok.colno, node, lookup); + } else { + break; + } + tok = this.peekToken(); + } + return node; + }; + _proto.parseExpression = function parseExpression() { + var node = this.parseInlineIf(); + return node; + }; + _proto.parseInlineIf = function parseInlineIf() { + var node = this.parseOr(); + if (this.skipSymbol("if")) { + var condNode = this.parseOr(); + var bodyNode = node; + node = new nodes2.InlineIf(node.lineno, node.colno); + node.body = bodyNode; + node.cond = condNode; + if (this.skipSymbol("else")) { + node.else_ = this.parseOr(); + } else { + node.else_ = null; + } + } + return node; + }; + _proto.parseOr = function parseOr() { + var node = this.parseAnd(); + while (this.skipSymbol("or")) { + var node2 = this.parseAnd(); + node = new nodes2.Or(node.lineno, node.colno, node, node2); + } + return node; + }; + _proto.parseAnd = function parseAnd() { + var node = this.parseNot(); + while (this.skipSymbol("and")) { + var node2 = this.parseNot(); + node = new nodes2.And(node.lineno, node.colno, node, node2); + } + return node; + }; + _proto.parseNot = function parseNot() { + var tok = this.peekToken(); + if (this.skipSymbol("not")) { + return new nodes2.Not(tok.lineno, tok.colno, this.parseNot()); + } + return this.parseIn(); + }; + _proto.parseIn = function parseIn() { + var node = this.parseIs(); + while (1) { + var tok = this.nextToken(); + if (!tok) { + break; + } + var invert = tok.type === lexer2.TOKEN_SYMBOL && tok.value === "not"; + if (!invert) { + this.pushToken(tok); + } + if (this.skipSymbol("in")) { + var node2 = this.parseIs(); + node = new nodes2.In(node.lineno, node.colno, node, node2); + if (invert) { + node = new nodes2.Not(node.lineno, node.colno, node); + } + } else { + if (invert) { + this.pushToken(tok); + } + break; + } + } + return node; + }; + _proto.parseIs = function parseIs() { + var node = this.parseCompare(); + if (this.skipSymbol("is")) { + var not = this.skipSymbol("not"); + var node2 = this.parseCompare(); + node = new nodes2.Is(node.lineno, node.colno, node, node2); + if (not) { + node = new nodes2.Not(node.lineno, node.colno, node); + } + } + return node; + }; + _proto.parseCompare = function parseCompare() { + var compareOps = ["==", "===", "!=", "!==", "<", ">", "<=", ">="]; + var expr = this.parseConcat(); + var ops = []; + while (1) { + var tok = this.nextToken(); + if (!tok) { + break; + } else if (compareOps.indexOf(tok.value) !== -1) { + ops.push(new nodes2.CompareOperand(tok.lineno, tok.colno, this.parseConcat(), tok.value)); + } else { + this.pushToken(tok); + break; + } + } + if (ops.length) { + return new nodes2.Compare(ops[0].lineno, ops[0].colno, expr, ops); + } else { + return expr; + } + }; + _proto.parseConcat = function parseConcat() { + var node = this.parseAdd(); + while (this.skipValue(lexer2.TOKEN_TILDE, "~")) { + var node2 = this.parseAdd(); + node = new nodes2.Concat(node.lineno, node.colno, node, node2); + } + return node; + }; + _proto.parseAdd = function parseAdd() { + var node = this.parseSub(); + while (this.skipValue(lexer2.TOKEN_OPERATOR, "+")) { + var node2 = this.parseSub(); + node = new nodes2.Add(node.lineno, node.colno, node, node2); + } + return node; + }; + _proto.parseSub = function parseSub() { + var node = this.parseMul(); + while (this.skipValue(lexer2.TOKEN_OPERATOR, "-")) { + var node2 = this.parseMul(); + node = new nodes2.Sub(node.lineno, node.colno, node, node2); + } + return node; + }; + _proto.parseMul = function parseMul() { + var node = this.parseDiv(); + while (this.skipValue(lexer2.TOKEN_OPERATOR, "*")) { + var node2 = this.parseDiv(); + node = new nodes2.Mul(node.lineno, node.colno, node, node2); + } + return node; + }; + _proto.parseDiv = function parseDiv() { + var node = this.parseFloorDiv(); + while (this.skipValue(lexer2.TOKEN_OPERATOR, "/")) { + var node2 = this.parseFloorDiv(); + node = new nodes2.Div(node.lineno, node.colno, node, node2); + } + return node; + }; + _proto.parseFloorDiv = function parseFloorDiv() { + var node = this.parseMod(); + while (this.skipValue(lexer2.TOKEN_OPERATOR, "//")) { + var node2 = this.parseMod(); + node = new nodes2.FloorDiv(node.lineno, node.colno, node, node2); + } + return node; + }; + _proto.parseMod = function parseMod() { + var node = this.parsePow(); + while (this.skipValue(lexer2.TOKEN_OPERATOR, "%")) { + var node2 = this.parsePow(); + node = new nodes2.Mod(node.lineno, node.colno, node, node2); + } + return node; + }; + _proto.parsePow = function parsePow() { + var node = this.parseUnary(); + while (this.skipValue(lexer2.TOKEN_OPERATOR, "**")) { + var node2 = this.parseUnary(); + node = new nodes2.Pow(node.lineno, node.colno, node, node2); + } + return node; + }; + _proto.parseUnary = function parseUnary(noFilters) { + var tok = this.peekToken(); + var node; + if (this.skipValue(lexer2.TOKEN_OPERATOR, "-")) { + node = new nodes2.Neg(tok.lineno, tok.colno, this.parseUnary(true)); + } else if (this.skipValue(lexer2.TOKEN_OPERATOR, "+")) { + node = new nodes2.Pos(tok.lineno, tok.colno, this.parseUnary(true)); + } else { + node = this.parsePrimary(); + } + if (!noFilters) { + node = this.parseFilter(node); + } + return node; + }; + _proto.parsePrimary = function parsePrimary(noPostfix) { + var tok = this.nextToken(); + var val; + var node = null; + if (!tok) { + this.fail("expected expression, got end of file"); + } else if (tok.type === lexer2.TOKEN_STRING) { + val = tok.value; + } else if (tok.type === lexer2.TOKEN_INT) { + val = parseInt(tok.value, 10); + } else if (tok.type === lexer2.TOKEN_FLOAT) { + val = parseFloat(tok.value); + } else if (tok.type === lexer2.TOKEN_BOOLEAN) { + if (tok.value === "true") { + val = true; + } else if (tok.value === "false") { + val = false; + } else { + this.fail("invalid boolean: " + tok.value, tok.lineno, tok.colno); + } + } else if (tok.type === lexer2.TOKEN_NONE) { + val = null; + } else if (tok.type === lexer2.TOKEN_REGEX) { + val = new RegExp(tok.value.body, tok.value.flags); + } + if (val !== void 0) { + node = new nodes2.Literal(tok.lineno, tok.colno, val); + } else if (tok.type === lexer2.TOKEN_SYMBOL) { + node = new nodes2.Symbol(tok.lineno, tok.colno, tok.value); + } else { + this.pushToken(tok); + node = this.parseAggregate(); + } + if (!noPostfix) { + node = this.parsePostfix(node); + } + if (node) { + return node; + } else { + throw this.error("unexpected token: " + tok.value, tok.lineno, tok.colno); + } + }; + _proto.parseFilterName = function parseFilterName() { + var tok = this.expect(lexer2.TOKEN_SYMBOL); + var name = tok.value; + while (this.skipValue(lexer2.TOKEN_OPERATOR, ".")) { + name += "." + this.expect(lexer2.TOKEN_SYMBOL).value; + } + return new nodes2.Symbol(tok.lineno, tok.colno, name); + }; + _proto.parseFilterArgs = function parseFilterArgs(node) { + if (this.peekToken().type === lexer2.TOKEN_LEFT_PAREN) { + var call = this.parsePostfix(node); + return call.args.children; + } + return []; + }; + _proto.parseFilter = function parseFilter(node) { + while (this.skip(lexer2.TOKEN_PIPE)) { + var name = this.parseFilterName(); + node = new nodes2.Filter(name.lineno, name.colno, name, new nodes2.NodeList(name.lineno, name.colno, [node].concat(this.parseFilterArgs(node)))); + } + return node; + }; + _proto.parseFilterStatement = function parseFilterStatement() { + var filterTok = this.peekToken(); + if (!this.skipSymbol("filter")) { + this.fail("parseFilterStatement: expected filter"); + } + var name = this.parseFilterName(); + var args = this.parseFilterArgs(name); + this.advanceAfterBlockEnd(filterTok.value); + var body = new nodes2.Capture(name.lineno, name.colno, this.parseUntilBlocks("endfilter")); + this.advanceAfterBlockEnd(); + var node = new nodes2.Filter(name.lineno, name.colno, name, new nodes2.NodeList(name.lineno, name.colno, [body].concat(args))); + return new nodes2.Output(name.lineno, name.colno, [node]); + }; + _proto.parseAggregate = function parseAggregate() { + var tok = this.nextToken(); + var node; + switch (tok.type) { + case lexer2.TOKEN_LEFT_PAREN: + node = new nodes2.Group(tok.lineno, tok.colno); + break; + case lexer2.TOKEN_LEFT_BRACKET: + node = new nodes2.Array(tok.lineno, tok.colno); + break; + case lexer2.TOKEN_LEFT_CURLY: + node = new nodes2.Dict(tok.lineno, tok.colno); + break; + default: + return null; + } + while (1) { + var type = this.peekToken().type; + if (type === lexer2.TOKEN_RIGHT_PAREN || type === lexer2.TOKEN_RIGHT_BRACKET || type === lexer2.TOKEN_RIGHT_CURLY) { + this.nextToken(); + break; + } + if (node.children.length > 0) { + if (!this.skip(lexer2.TOKEN_COMMA)) { + this.fail("parseAggregate: expected comma after expression", tok.lineno, tok.colno); + } + } + if (node instanceof nodes2.Dict) { + var key = this.parsePrimary(); + if (!this.skip(lexer2.TOKEN_COLON)) { + this.fail("parseAggregate: expected colon after dict key", tok.lineno, tok.colno); + } + var value = this.parseExpression(); + node.addChild(new nodes2.Pair(key.lineno, key.colno, key, value)); + } else { + var expr = this.parseExpression(); + node.addChild(expr); + } + } + return node; + }; + _proto.parseSignature = function parseSignature(tolerant, noParens) { + var tok = this.peekToken(); + if (!noParens && tok.type !== lexer2.TOKEN_LEFT_PAREN) { + if (tolerant) { + return null; + } else { + this.fail("expected arguments", tok.lineno, tok.colno); + } + } + if (tok.type === lexer2.TOKEN_LEFT_PAREN) { + tok = this.nextToken(); + } + var args = new nodes2.NodeList(tok.lineno, tok.colno); + var kwargs = new nodes2.KeywordArgs(tok.lineno, tok.colno); + var checkComma = false; + while (1) { + tok = this.peekToken(); + if (!noParens && tok.type === lexer2.TOKEN_RIGHT_PAREN) { + this.nextToken(); + break; + } else if (noParens && tok.type === lexer2.TOKEN_BLOCK_END) { + break; + } + if (checkComma && !this.skip(lexer2.TOKEN_COMMA)) { + this.fail("parseSignature: expected comma after expression", tok.lineno, tok.colno); + } else { + var arg = this.parseExpression(); + if (this.skipValue(lexer2.TOKEN_OPERATOR, "=")) { + kwargs.addChild(new nodes2.Pair(arg.lineno, arg.colno, arg, this.parseExpression())); + } else { + args.addChild(arg); + } + } + checkComma = true; + } + if (kwargs.children.length) { + args.addChild(kwargs); + } + return args; + }; + _proto.parseUntilBlocks = function parseUntilBlocks() { + var prev = this.breakOnBlocks; + for (var _len = arguments.length, blockNames = new Array(_len), _key = 0; _key < _len; _key++) { + blockNames[_key] = arguments[_key]; + } + this.breakOnBlocks = blockNames; + var ret = this.parse(); + this.breakOnBlocks = prev; + return ret; + }; + _proto.parseNodes = function parseNodes() { + var tok; + var buf = []; + while (tok = this.nextToken()) { + if (tok.type === lexer2.TOKEN_DATA) { + var data = tok.value; + var nextToken = this.peekToken(); + var nextVal = nextToken && nextToken.value; + if (this.dropLeadingWhitespace) { + data = data.replace(/^\s*/, ""); + this.dropLeadingWhitespace = false; + } + if (nextToken && (nextToken.type === lexer2.TOKEN_BLOCK_START && nextVal.charAt(nextVal.length - 1) === "-" || nextToken.type === lexer2.TOKEN_VARIABLE_START && nextVal.charAt(this.tokens.tags.VARIABLE_START.length) === "-" || nextToken.type === lexer2.TOKEN_COMMENT && nextVal.charAt(this.tokens.tags.COMMENT_START.length) === "-")) { + data = data.replace(/\s*$/, ""); + } + buf.push(new nodes2.Output(tok.lineno, tok.colno, [new nodes2.TemplateData(tok.lineno, tok.colno, data)])); + } else if (tok.type === lexer2.TOKEN_BLOCK_START) { + this.dropLeadingWhitespace = false; + var n = this.parseStatement(); + if (!n) { + break; + } + buf.push(n); + } else if (tok.type === lexer2.TOKEN_VARIABLE_START) { + var e2 = this.parseExpression(); + this.dropLeadingWhitespace = false; + this.advanceAfterVariableEnd(); + buf.push(new nodes2.Output(tok.lineno, tok.colno, [e2])); + } else if (tok.type === lexer2.TOKEN_COMMENT) { + this.dropLeadingWhitespace = tok.value.charAt(tok.value.length - this.tokens.tags.COMMENT_END.length - 1) === "-"; + } else { + this.fail("Unexpected token at top-level: " + tok.type, tok.lineno, tok.colno); + } + } + return buf; + }; + _proto.parse = function parse() { + return new nodes2.NodeList(0, 0, this.parseNodes()); + }; + _proto.parseAsRoot = function parseAsRoot() { + return new nodes2.Root(0, 0, this.parseNodes()); + }; + return Parser2; + }(Obj); + module2.exports = { + parse: function parse(src, extensions, opts) { + var p = new Parser(lexer2.lex(src, opts)); + if (extensions !== void 0) { + p.extensions = extensions; + } + return p.parseAsRoot(); + }, + Parser + }; +}); + +// ../../node_modules/nunjucks/src/transformer.js +var require_transformer = __commonJS((exports2, module2) => { + "use strict"; + var nodes2 = require_nodes(); + var lib2 = require_lib(); + var sym = 0; + function gensym() { + return "hole_" + sym++; + } + function mapCOW(arr, func) { + var res = null; + for (var i = 0; i < arr.length; i++) { + var item = func(arr[i]); + if (item !== arr[i]) { + if (!res) { + res = arr.slice(); + } + res[i] = item; + } + } + return res || arr; + } + function walk(ast, func, depthFirst) { + if (!(ast instanceof nodes2.Node)) { + return ast; + } + if (!depthFirst) { + var astT = func(ast); + if (astT && astT !== ast) { + return astT; + } + } + if (ast instanceof nodes2.NodeList) { + var children = mapCOW(ast.children, function(node) { + return walk(node, func, depthFirst); + }); + if (children !== ast.children) { + ast = new nodes2[ast.typename](ast.lineno, ast.colno, children); + } + } else if (ast instanceof nodes2.CallExtension) { + var args = walk(ast.args, func, depthFirst); + var contentArgs = mapCOW(ast.contentArgs, function(node) { + return walk(node, func, depthFirst); + }); + if (args !== ast.args || contentArgs !== ast.contentArgs) { + ast = new nodes2[ast.typename](ast.extName, ast.prop, args, contentArgs); + } + } else { + var props = ast.fields.map(function(field) { + return ast[field]; + }); + var propsT = mapCOW(props, function(prop) { + return walk(prop, func, depthFirst); + }); + if (propsT !== props) { + ast = new nodes2[ast.typename](ast.lineno, ast.colno); + propsT.forEach(function(prop, i) { + ast[ast.fields[i]] = prop; + }); + } + } + return depthFirst ? func(ast) || ast : ast; + } + function depthWalk(ast, func) { + return walk(ast, func, true); + } + function _liftFilters(node, asyncFilters, prop) { + var children = []; + var walked = depthWalk(prop ? node[prop] : node, function(descNode) { + var symbol; + if (descNode instanceof nodes2.Block) { + return descNode; + } else if (descNode instanceof nodes2.Filter && lib2.indexOf(asyncFilters, descNode.name.value) !== -1 || descNode instanceof nodes2.CallExtensionAsync) { + symbol = new nodes2.Symbol(descNode.lineno, descNode.colno, gensym()); + children.push(new nodes2.FilterAsync(descNode.lineno, descNode.colno, descNode.name, descNode.args, symbol)); + } + return symbol; + }); + if (prop) { + node[prop] = walked; + } else { + node = walked; + } + if (children.length) { + children.push(node); + return new nodes2.NodeList(node.lineno, node.colno, children); + } else { + return node; + } + } + function liftFilters(ast, asyncFilters) { + return depthWalk(ast, function(node) { + if (node instanceof nodes2.Output) { + return _liftFilters(node, asyncFilters); + } else if (node instanceof nodes2.Set) { + return _liftFilters(node, asyncFilters, "value"); + } else if (node instanceof nodes2.For) { + return _liftFilters(node, asyncFilters, "arr"); + } else if (node instanceof nodes2.If) { + return _liftFilters(node, asyncFilters, "cond"); + } else if (node instanceof nodes2.CallExtension) { + return _liftFilters(node, asyncFilters, "args"); + } else { + return void 0; + } + }); + } + function liftSuper(ast) { + return walk(ast, function(blockNode) { + if (!(blockNode instanceof nodes2.Block)) { + return; + } + var hasSuper = false; + var symbol = gensym(); + blockNode.body = walk(blockNode.body, function(node) { + if (node instanceof nodes2.FunCall && node.name.value === "super") { + hasSuper = true; + return new nodes2.Symbol(node.lineno, node.colno, symbol); + } + }); + if (hasSuper) { + blockNode.body.children.unshift(new nodes2.Super(0, 0, blockNode.name, new nodes2.Symbol(0, 0, symbol))); + } + }); + } + function convertStatements(ast) { + return depthWalk(ast, function(node) { + if (!(node instanceof nodes2.If) && !(node instanceof nodes2.For)) { + return void 0; + } + var async = false; + walk(node, function(child) { + if (child instanceof nodes2.FilterAsync || child instanceof nodes2.IfAsync || child instanceof nodes2.AsyncEach || child instanceof nodes2.AsyncAll || child instanceof nodes2.CallExtensionAsync) { + async = true; + return child; + } + return void 0; + }); + if (async) { + if (node instanceof nodes2.If) { + return new nodes2.IfAsync(node.lineno, node.colno, node.cond, node.body, node.else_); + } else if (node instanceof nodes2.For && !(node instanceof nodes2.AsyncAll)) { + return new nodes2.AsyncEach(node.lineno, node.colno, node.arr, node.name, node.body, node.else_); + } + } + return void 0; + }); + } + function cps(ast, asyncFilters) { + return convertStatements(liftSuper(liftFilters(ast, asyncFilters))); + } + function transform(ast, asyncFilters) { + return cps(ast, asyncFilters || []); + } + module2.exports = { + transform + }; +}); + +// ../../node_modules/nunjucks/src/runtime.js +var require_runtime = __commonJS((exports2, module2) => { + "use strict"; + var lib2 = require_lib(); + var arrayFrom = Array.from; + var supportsIterators = typeof Symbol === "function" && Symbol.iterator && typeof arrayFrom === "function"; + var Frame = /* @__PURE__ */ function() { + function Frame2(parent, isolateWrites) { + this.variables = Object.create(null); + this.parent = parent; + this.topLevel = false; + this.isolateWrites = isolateWrites; + } + var _proto = Frame2.prototype; + _proto.set = function set(name, val, resolveUp) { + var parts = name.split("."); + var obj = this.variables; + var frame = this; + if (resolveUp) { + if (frame = this.resolve(parts[0], true)) { + frame.set(name, val); + return; + } + } + for (var i = 0; i < parts.length - 1; i++) { + var id = parts[i]; + if (!obj[id]) { + obj[id] = {}; + } + obj = obj[id]; + } + obj[parts[parts.length - 1]] = val; + }; + _proto.get = function get(name) { + var val = this.variables[name]; + if (val !== void 0) { + return val; + } + return null; + }; + _proto.lookup = function lookup(name) { + var p = this.parent; + var val = this.variables[name]; + if (val !== void 0) { + return val; + } + return p && p.lookup(name); + }; + _proto.resolve = function resolve(name, forWrite) { + var p = forWrite && this.isolateWrites ? void 0 : this.parent; + var val = this.variables[name]; + if (val !== void 0) { + return this; + } + return p && p.resolve(name); + }; + _proto.push = function push(isolateWrites) { + return new Frame2(this, isolateWrites); + }; + _proto.pop = function pop() { + return this.parent; + }; + return Frame2; + }(); + function makeMacro(argNames, kwargNames, func) { + return function macro() { + for (var _len = arguments.length, macroArgs = new Array(_len), _key = 0; _key < _len; _key++) { + macroArgs[_key] = arguments[_key]; + } + var argCount = numArgs(macroArgs); + var args; + var kwargs = getKeywordArgs(macroArgs); + if (argCount > argNames.length) { + args = macroArgs.slice(0, argNames.length); + macroArgs.slice(args.length, argCount).forEach(function(val, i2) { + if (i2 < kwargNames.length) { + kwargs[kwargNames[i2]] = val; + } + }); + args.push(kwargs); + } else if (argCount < argNames.length) { + args = macroArgs.slice(0, argCount); + for (var i = argCount; i < argNames.length; i++) { + var arg = argNames[i]; + args.push(kwargs[arg]); + delete kwargs[arg]; + } + args.push(kwargs); + } else { + args = macroArgs; + } + return func.apply(this, args); + }; + } + function makeKeywordArgs(obj) { + obj.__keywords = true; + return obj; + } + function isKeywordArgs(obj) { + return obj && Object.prototype.hasOwnProperty.call(obj, "__keywords"); + } + function getKeywordArgs(args) { + var len = args.length; + if (len) { + var lastArg = args[len - 1]; + if (isKeywordArgs(lastArg)) { + return lastArg; + } + } + return {}; + } + function numArgs(args) { + var len = args.length; + if (len === 0) { + return 0; + } + var lastArg = args[len - 1]; + if (isKeywordArgs(lastArg)) { + return len - 1; + } else { + return len; + } + } + function SafeString(val) { + if (typeof val !== "string") { + return val; + } + this.val = val; + this.length = val.length; + } + SafeString.prototype = Object.create(String.prototype, { + length: { + writable: true, + configurable: true, + value: 0 + } + }); + SafeString.prototype.valueOf = function valueOf() { + return this.val; + }; + SafeString.prototype.toString = function toString() { + return this.val; + }; + function copySafeness(dest, target) { + if (dest instanceof SafeString) { + return new SafeString(target); + } + return target.toString(); + } + function markSafe(val) { + var type = typeof val; + if (type === "string") { + return new SafeString(val); + } else if (type !== "function") { + return val; + } else { + return function wrapSafe(args) { + var ret = val.apply(this, arguments); + if (typeof ret === "string") { + return new SafeString(ret); + } + return ret; + }; + } + } + function suppressValue(val, autoescape) { + val = val !== void 0 && val !== null ? val : ""; + if (autoescape && !(val instanceof SafeString)) { + val = lib2.escape(val.toString()); + } + return val; + } + function ensureDefined(val, lineno, colno) { + if (val === null || val === void 0) { + throw new lib2.TemplateError("attempted to output null or undefined value", lineno + 1, colno + 1); + } + return val; + } + function memberLookup(obj, val) { + if (obj === void 0 || obj === null) { + return void 0; + } + if (typeof obj[val] === "function") { + return function() { + for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + args[_key2] = arguments[_key2]; + } + return obj[val].apply(obj, args); + }; + } + return obj[val]; + } + function callWrap(obj, name, context, args) { + if (!obj) { + throw new Error("Unable to call `" + name + "`, which is undefined or falsey"); + } else if (typeof obj !== "function") { + throw new Error("Unable to call `" + name + "`, which is not a function"); + } + return obj.apply(context, args); + } + function contextOrFrameLookup(context, frame, name) { + var val = frame.lookup(name); + return val !== void 0 ? val : context.lookup(name); + } + function handleError(error, lineno, colno) { + if (error.lineno) { + return error; + } else { + return new lib2.TemplateError(error, lineno, colno); + } + } + function asyncEach(arr, dimen, iter, cb) { + if (lib2.isArray(arr)) { + var len = arr.length; + lib2.asyncIter(arr, function iterCallback(item, i, next) { + switch (dimen) { + case 1: + iter(item, i, len, next); + break; + case 2: + iter(item[0], item[1], i, len, next); + break; + case 3: + iter(item[0], item[1], item[2], i, len, next); + break; + default: + item.push(i, len, next); + iter.apply(this, item); + } + }, cb); + } else { + lib2.asyncFor(arr, function iterCallback(key, val, i, len2, next) { + iter(key, val, i, len2, next); + }, cb); + } + } + function asyncAll(arr, dimen, func, cb) { + var finished = 0; + var len; + var outputArr; + function done(i2, output) { + finished++; + outputArr[i2] = output; + if (finished === len) { + cb(null, outputArr.join("")); + } + } + if (lib2.isArray(arr)) { + len = arr.length; + outputArr = new Array(len); + if (len === 0) { + cb(null, ""); + } else { + for (var i = 0; i < arr.length; i++) { + var item = arr[i]; + switch (dimen) { + case 1: + func(item, i, len, done); + break; + case 2: + func(item[0], item[1], i, len, done); + break; + case 3: + func(item[0], item[1], item[2], i, len, done); + break; + default: + item.push(i, len, done); + func.apply(this, item); + } + } + } + } else { + var keys = lib2.keys(arr || {}); + len = keys.length; + outputArr = new Array(len); + if (len === 0) { + cb(null, ""); + } else { + for (var _i = 0; _i < keys.length; _i++) { + var k = keys[_i]; + func(k, arr[k], _i, len, done); + } + } + } + } + function fromIterator(arr) { + if (typeof arr !== "object" || arr === null || lib2.isArray(arr)) { + return arr; + } else if (supportsIterators && Symbol.iterator in arr) { + return arrayFrom(arr); + } else { + return arr; + } + } + module2.exports = { + Frame, + makeMacro, + makeKeywordArgs, + numArgs, + suppressValue, + ensureDefined, + memberLookup, + contextOrFrameLookup, + callWrap, + handleError, + isArray: lib2.isArray, + keys: lib2.keys, + SafeString, + copySafeness, + markSafe, + asyncEach, + asyncAll, + inOperator: lib2.inOperator, + fromIterator + }; +}); + +// ../../node_modules/nunjucks/src/compiler.js +var require_compiler = __commonJS((exports2, module2) => { + "use strict"; + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + _setPrototypeOf(subClass, superClass); + } + function _setPrototypeOf(o, p) { + _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf2(o2, p2) { + o2.__proto__ = p2; + return o2; + }; + return _setPrototypeOf(o, p); + } + var parser2 = require_parser(); + var transformer = require_transformer(); + var nodes2 = require_nodes(); + var _require2 = require_lib(); + var TemplateError = _require2.TemplateError; + var _require22 = require_runtime(); + var Frame = _require22.Frame; + var _require3 = require_object(); + var Obj = _require3.Obj; + var compareOps = { + "==": "==", + "===": "===", + "!=": "!=", + "!==": "!==", + "<": "<", + ">": ">", + "<=": "<=", + ">=": ">=" + }; + var Compiler = /* @__PURE__ */ function(_Obj) { + _inheritsLoose(Compiler2, _Obj); + function Compiler2() { + return _Obj.apply(this, arguments) || this; + } + var _proto = Compiler2.prototype; + _proto.init = function init(templateName, throwOnUndefined) { + this.templateName = templateName; + this.codebuf = []; + this.lastId = 0; + this.buffer = null; + this.bufferStack = []; + this._scopeClosers = ""; + this.inBlock = false; + this.throwOnUndefined = throwOnUndefined; + }; + _proto.fail = function fail(msg, lineno, colno) { + if (lineno !== void 0) { + lineno += 1; + } + if (colno !== void 0) { + colno += 1; + } + throw new TemplateError(msg, lineno, colno); + }; + _proto._pushBuffer = function _pushBuffer() { + var id = this._tmpid(); + this.bufferStack.push(this.buffer); + this.buffer = id; + this._emit("var " + this.buffer + ' = "";'); + return id; + }; + _proto._popBuffer = function _popBuffer() { + this.buffer = this.bufferStack.pop(); + }; + _proto._emit = function _emit(code) { + this.codebuf.push(code); + }; + _proto._emitLine = function _emitLine(code) { + this._emit(code + "\n"); + }; + _proto._emitLines = function _emitLines() { + var _this = this; + for (var _len = arguments.length, lines = new Array(_len), _key = 0; _key < _len; _key++) { + lines[_key] = arguments[_key]; + } + lines.forEach(function(line) { + return _this._emitLine(line); + }); + }; + _proto._emitFuncBegin = function _emitFuncBegin(node, name) { + this.buffer = "output"; + this._scopeClosers = ""; + this._emitLine("function " + name + "(env, context, frame, runtime, cb) {"); + this._emitLine("var lineno = " + node.lineno + ";"); + this._emitLine("var colno = " + node.colno + ";"); + this._emitLine("var " + this.buffer + ' = "";'); + this._emitLine("try {"); + }; + _proto._emitFuncEnd = function _emitFuncEnd(noReturn) { + if (!noReturn) { + this._emitLine("cb(null, " + this.buffer + ");"); + } + this._closeScopeLevels(); + this._emitLine("} catch (e) {"); + this._emitLine(" cb(runtime.handleError(e, lineno, colno));"); + this._emitLine("}"); + this._emitLine("}"); + this.buffer = null; + }; + _proto._addScopeLevel = function _addScopeLevel() { + this._scopeClosers += "})"; + }; + _proto._closeScopeLevels = function _closeScopeLevels() { + this._emitLine(this._scopeClosers + ";"); + this._scopeClosers = ""; + }; + _proto._withScopedSyntax = function _withScopedSyntax(func) { + var _scopeClosers = this._scopeClosers; + this._scopeClosers = ""; + func.call(this); + this._closeScopeLevels(); + this._scopeClosers = _scopeClosers; + }; + _proto._makeCallback = function _makeCallback(res) { + var err = this._tmpid(); + return "function(" + err + (res ? "," + res : "") + ") {\nif(" + err + ") { cb(" + err + "); return; }"; + }; + _proto._tmpid = function _tmpid() { + this.lastId++; + return "t_" + this.lastId; + }; + _proto._templateName = function _templateName() { + return this.templateName == null ? "undefined" : JSON.stringify(this.templateName); + }; + _proto._compileChildren = function _compileChildren(node, frame) { + var _this2 = this; + node.children.forEach(function(child) { + _this2.compile(child, frame); + }); + }; + _proto._compileAggregate = function _compileAggregate(node, frame, startChar, endChar) { + var _this3 = this; + if (startChar) { + this._emit(startChar); + } + node.children.forEach(function(child, i) { + if (i > 0) { + _this3._emit(","); + } + _this3.compile(child, frame); + }); + if (endChar) { + this._emit(endChar); + } + }; + _proto._compileExpression = function _compileExpression(node, frame) { + this.assertType(node, nodes2.Literal, nodes2.Symbol, nodes2.Group, nodes2.Array, nodes2.Dict, nodes2.FunCall, nodes2.Caller, nodes2.Filter, nodes2.LookupVal, nodes2.Compare, nodes2.InlineIf, nodes2.In, nodes2.Is, nodes2.And, nodes2.Or, nodes2.Not, nodes2.Add, nodes2.Concat, nodes2.Sub, nodes2.Mul, nodes2.Div, nodes2.FloorDiv, nodes2.Mod, nodes2.Pow, nodes2.Neg, nodes2.Pos, nodes2.Compare, nodes2.NodeList); + this.compile(node, frame); + }; + _proto.assertType = function assertType(node) { + for (var _len2 = arguments.length, types = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { + types[_key2 - 1] = arguments[_key2]; + } + if (!types.some(function(t) { + return node instanceof t; + })) { + this.fail("assertType: invalid type: " + node.typename, node.lineno, node.colno); + } + }; + _proto.compileCallExtension = function compileCallExtension(node, frame, async) { + var _this4 = this; + var args = node.args; + var contentArgs = node.contentArgs; + var autoescape = typeof node.autoescape === "boolean" ? node.autoescape : true; + if (!async) { + this._emit(this.buffer + " += runtime.suppressValue("); + } + this._emit('env.getExtension("' + node.extName + '")["' + node.prop + '"]('); + this._emit("context"); + if (args || contentArgs) { + this._emit(","); + } + if (args) { + if (!(args instanceof nodes2.NodeList)) { + this.fail("compileCallExtension: arguments must be a NodeList, use `parser.parseSignature`"); + } + args.children.forEach(function(arg, i) { + _this4._compileExpression(arg, frame); + if (i !== args.children.length - 1 || contentArgs.length) { + _this4._emit(","); + } + }); + } + if (contentArgs.length) { + contentArgs.forEach(function(arg, i) { + if (i > 0) { + _this4._emit(","); + } + if (arg) { + _this4._emitLine("function(cb) {"); + _this4._emitLine("if(!cb) { cb = function(err) { if(err) { throw err; }}}"); + var id = _this4._pushBuffer(); + _this4._withScopedSyntax(function() { + _this4.compile(arg, frame); + _this4._emitLine("cb(null, " + id + ");"); + }); + _this4._popBuffer(); + _this4._emitLine("return " + id + ";"); + _this4._emitLine("}"); + } else { + _this4._emit("null"); + } + }); + } + if (async) { + var res = this._tmpid(); + this._emitLine(", " + this._makeCallback(res)); + this._emitLine(this.buffer + " += runtime.suppressValue(" + res + ", " + autoescape + " && env.opts.autoescape);"); + this._addScopeLevel(); + } else { + this._emit(")"); + this._emit(", " + autoescape + " && env.opts.autoescape);\n"); + } + }; + _proto.compileCallExtensionAsync = function compileCallExtensionAsync(node, frame) { + this.compileCallExtension(node, frame, true); + }; + _proto.compileNodeList = function compileNodeList(node, frame) { + this._compileChildren(node, frame); + }; + _proto.compileLiteral = function compileLiteral(node) { + if (typeof node.value === "string") { + var val = node.value.replace(/\\/g, "\\\\"); + val = val.replace(/"/g, '\\"'); + val = val.replace(/\n/g, "\\n"); + val = val.replace(/\r/g, "\\r"); + val = val.replace(/\t/g, "\\t"); + val = val.replace(/\u2028/g, "\\u2028"); + this._emit('"' + val + '"'); + } else if (node.value === null) { + this._emit("null"); + } else { + this._emit(node.value.toString()); + } + }; + _proto.compileSymbol = function compileSymbol(node, frame) { + var name = node.value; + var v = frame.lookup(name); + if (v) { + this._emit(v); + } else { + this._emit('runtime.contextOrFrameLookup(context, frame, "' + name + '")'); + } + }; + _proto.compileGroup = function compileGroup(node, frame) { + this._compileAggregate(node, frame, "(", ")"); + }; + _proto.compileArray = function compileArray(node, frame) { + this._compileAggregate(node, frame, "[", "]"); + }; + _proto.compileDict = function compileDict(node, frame) { + this._compileAggregate(node, frame, "{", "}"); + }; + _proto.compilePair = function compilePair(node, frame) { + var key = node.key; + var val = node.value; + if (key instanceof nodes2.Symbol) { + key = new nodes2.Literal(key.lineno, key.colno, key.value); + } else if (!(key instanceof nodes2.Literal && typeof key.value === "string")) { + this.fail("compilePair: Dict keys must be strings or names", key.lineno, key.colno); + } + this.compile(key, frame); + this._emit(": "); + this._compileExpression(val, frame); + }; + _proto.compileInlineIf = function compileInlineIf(node, frame) { + this._emit("("); + this.compile(node.cond, frame); + this._emit("?"); + this.compile(node.body, frame); + this._emit(":"); + if (node.else_ !== null) { + this.compile(node.else_, frame); + } else { + this._emit('""'); + } + this._emit(")"); + }; + _proto.compileIn = function compileIn(node, frame) { + this._emit("runtime.inOperator("); + this.compile(node.left, frame); + this._emit(","); + this.compile(node.right, frame); + this._emit(")"); + }; + _proto.compileIs = function compileIs(node, frame) { + var right = node.right.name ? node.right.name.value : node.right.value; + this._emit('env.getTest("' + right + '").call(context, '); + this.compile(node.left, frame); + if (node.right.args) { + this._emit(","); + this.compile(node.right.args, frame); + } + this._emit(") === true"); + }; + _proto._binOpEmitter = function _binOpEmitter(node, frame, str) { + this.compile(node.left, frame); + this._emit(str); + this.compile(node.right, frame); + }; + _proto.compileOr = function compileOr(node, frame) { + return this._binOpEmitter(node, frame, " || "); + }; + _proto.compileAnd = function compileAnd(node, frame) { + return this._binOpEmitter(node, frame, " && "); + }; + _proto.compileAdd = function compileAdd(node, frame) { + return this._binOpEmitter(node, frame, " + "); + }; + _proto.compileConcat = function compileConcat(node, frame) { + return this._binOpEmitter(node, frame, ' + "" + '); + }; + _proto.compileSub = function compileSub(node, frame) { + return this._binOpEmitter(node, frame, " - "); + }; + _proto.compileMul = function compileMul(node, frame) { + return this._binOpEmitter(node, frame, " * "); + }; + _proto.compileDiv = function compileDiv(node, frame) { + return this._binOpEmitter(node, frame, " / "); + }; + _proto.compileMod = function compileMod(node, frame) { + return this._binOpEmitter(node, frame, " % "); + }; + _proto.compileNot = function compileNot(node, frame) { + this._emit("!"); + this.compile(node.target, frame); + }; + _proto.compileFloorDiv = function compileFloorDiv(node, frame) { + this._emit("Math.floor("); + this.compile(node.left, frame); + this._emit(" / "); + this.compile(node.right, frame); + this._emit(")"); + }; + _proto.compilePow = function compilePow(node, frame) { + this._emit("Math.pow("); + this.compile(node.left, frame); + this._emit(", "); + this.compile(node.right, frame); + this._emit(")"); + }; + _proto.compileNeg = function compileNeg(node, frame) { + this._emit("-"); + this.compile(node.target, frame); + }; + _proto.compilePos = function compilePos(node, frame) { + this._emit("+"); + this.compile(node.target, frame); + }; + _proto.compileCompare = function compileCompare(node, frame) { + var _this5 = this; + this.compile(node.expr, frame); + node.ops.forEach(function(op) { + _this5._emit(" " + compareOps[op.type] + " "); + _this5.compile(op.expr, frame); + }); + }; + _proto.compileLookupVal = function compileLookupVal(node, frame) { + this._emit("runtime.memberLookup(("); + this._compileExpression(node.target, frame); + this._emit("),"); + this._compileExpression(node.val, frame); + this._emit(")"); + }; + _proto._getNodeName = function _getNodeName(node) { + switch (node.typename) { + case "Symbol": + return node.value; + case "FunCall": + return "the return value of (" + this._getNodeName(node.name) + ")"; + case "LookupVal": + return this._getNodeName(node.target) + '["' + this._getNodeName(node.val) + '"]'; + case "Literal": + return node.value.toString(); + default: + return "--expression--"; + } + }; + _proto.compileFunCall = function compileFunCall(node, frame) { + this._emit("(lineno = " + node.lineno + ", colno = " + node.colno + ", "); + this._emit("runtime.callWrap("); + this._compileExpression(node.name, frame); + this._emit(', "' + this._getNodeName(node.name).replace(/"/g, '\\"') + '", context, '); + this._compileAggregate(node.args, frame, "[", "])"); + this._emit(")"); + }; + _proto.compileFilter = function compileFilter(node, frame) { + var name = node.name; + this.assertType(name, nodes2.Symbol); + this._emit('env.getFilter("' + name.value + '").call(context, '); + this._compileAggregate(node.args, frame); + this._emit(")"); + }; + _proto.compileFilterAsync = function compileFilterAsync(node, frame) { + var name = node.name; + var symbol = node.symbol.value; + this.assertType(name, nodes2.Symbol); + frame.set(symbol, symbol); + this._emit('env.getFilter("' + name.value + '").call(context, '); + this._compileAggregate(node.args, frame); + this._emitLine(", " + this._makeCallback(symbol)); + this._addScopeLevel(); + }; + _proto.compileKeywordArgs = function compileKeywordArgs(node, frame) { + this._emit("runtime.makeKeywordArgs("); + this.compileDict(node, frame); + this._emit(")"); + }; + _proto.compileSet = function compileSet(node, frame) { + var _this6 = this; + var ids = []; + node.targets.forEach(function(target) { + var name = target.value; + var id = frame.lookup(name); + if (id === null || id === void 0) { + id = _this6._tmpid(); + _this6._emitLine("var " + id + ";"); + } + ids.push(id); + }); + if (node.value) { + this._emit(ids.join(" = ") + " = "); + this._compileExpression(node.value, frame); + this._emitLine(";"); + } else { + this._emit(ids.join(" = ") + " = "); + this.compile(node.body, frame); + this._emitLine(";"); + } + node.targets.forEach(function(target, i) { + var id = ids[i]; + var name = target.value; + _this6._emitLine('frame.set("' + name + '", ' + id + ", true);"); + _this6._emitLine("if(frame.topLevel) {"); + _this6._emitLine('context.setVariable("' + name + '", ' + id + ");"); + _this6._emitLine("}"); + if (name.charAt(0) !== "_") { + _this6._emitLine("if(frame.topLevel) {"); + _this6._emitLine('context.addExport("' + name + '", ' + id + ");"); + _this6._emitLine("}"); + } + }); + }; + _proto.compileSwitch = function compileSwitch(node, frame) { + var _this7 = this; + this._emit("switch ("); + this.compile(node.expr, frame); + this._emit(") {"); + node.cases.forEach(function(c, i) { + _this7._emit("case "); + _this7.compile(c.cond, frame); + _this7._emit(": "); + _this7.compile(c.body, frame); + if (c.body.children.length) { + _this7._emitLine("break;"); + } + }); + if (node.default) { + this._emit("default:"); + this.compile(node.default, frame); + } + this._emit("}"); + }; + _proto.compileIf = function compileIf(node, frame, async) { + var _this8 = this; + this._emit("if("); + this._compileExpression(node.cond, frame); + this._emitLine(") {"); + this._withScopedSyntax(function() { + _this8.compile(node.body, frame); + if (async) { + _this8._emit("cb()"); + } + }); + if (node.else_) { + this._emitLine("}\nelse {"); + this._withScopedSyntax(function() { + _this8.compile(node.else_, frame); + if (async) { + _this8._emit("cb()"); + } + }); + } else if (async) { + this._emitLine("}\nelse {"); + this._emit("cb()"); + } + this._emitLine("}"); + }; + _proto.compileIfAsync = function compileIfAsync(node, frame) { + this._emit("(function(cb) {"); + this.compileIf(node, frame, true); + this._emit("})(" + this._makeCallback()); + this._addScopeLevel(); + }; + _proto._emitLoopBindings = function _emitLoopBindings(node, arr, i, len) { + var _this9 = this; + var bindings = [{ + name: "index", + val: i + " + 1" + }, { + name: "index0", + val: i + }, { + name: "revindex", + val: len + " - " + i + }, { + name: "revindex0", + val: len + " - " + i + " - 1" + }, { + name: "first", + val: i + " === 0" + }, { + name: "last", + val: i + " === " + len + " - 1" + }, { + name: "length", + val: len + }]; + bindings.forEach(function(b) { + _this9._emitLine('frame.set("loop.' + b.name + '", ' + b.val + ");"); + }); + }; + _proto.compileFor = function compileFor(node, frame) { + var _this10 = this; + var i = this._tmpid(); + var len = this._tmpid(); + var arr = this._tmpid(); + frame = frame.push(); + this._emitLine("frame = frame.push();"); + this._emit("var " + arr + " = "); + this._compileExpression(node.arr, frame); + this._emitLine(";"); + this._emit("if(" + arr + ") {"); + this._emitLine(arr + " = runtime.fromIterator(" + arr + ");"); + if (node.name instanceof nodes2.Array) { + this._emitLine("var " + i + ";"); + this._emitLine("if(runtime.isArray(" + arr + ")) {"); + this._emitLine("var " + len + " = " + arr + ".length;"); + this._emitLine("for(" + i + "=0; " + i + " < " + arr + ".length; " + i + "++) {"); + node.name.children.forEach(function(child, u) { + var tid = _this10._tmpid(); + _this10._emitLine("var " + tid + " = " + arr + "[" + i + "][" + u + "];"); + _this10._emitLine('frame.set("' + child + '", ' + arr + "[" + i + "][" + u + "]);"); + frame.set(node.name.children[u].value, tid); + }); + this._emitLoopBindings(node, arr, i, len); + this._withScopedSyntax(function() { + _this10.compile(node.body, frame); + }); + this._emitLine("}"); + this._emitLine("} else {"); + var _node$name$children = node.name.children, key = _node$name$children[0], val = _node$name$children[1]; + var k = this._tmpid(); + var v = this._tmpid(); + frame.set(key.value, k); + frame.set(val.value, v); + this._emitLine(i + " = -1;"); + this._emitLine("var " + len + " = runtime.keys(" + arr + ").length;"); + this._emitLine("for(var " + k + " in " + arr + ") {"); + this._emitLine(i + "++;"); + this._emitLine("var " + v + " = " + arr + "[" + k + "];"); + this._emitLine('frame.set("' + key.value + '", ' + k + ");"); + this._emitLine('frame.set("' + val.value + '", ' + v + ");"); + this._emitLoopBindings(node, arr, i, len); + this._withScopedSyntax(function() { + _this10.compile(node.body, frame); + }); + this._emitLine("}"); + this._emitLine("}"); + } else { + var _v = this._tmpid(); + frame.set(node.name.value, _v); + this._emitLine("var " + len + " = " + arr + ".length;"); + this._emitLine("for(var " + i + "=0; " + i + " < " + arr + ".length; " + i + "++) {"); + this._emitLine("var " + _v + " = " + arr + "[" + i + "];"); + this._emitLine('frame.set("' + node.name.value + '", ' + _v + ");"); + this._emitLoopBindings(node, arr, i, len); + this._withScopedSyntax(function() { + _this10.compile(node.body, frame); + }); + this._emitLine("}"); + } + this._emitLine("}"); + if (node.else_) { + this._emitLine("if (!" + len + ") {"); + this.compile(node.else_, frame); + this._emitLine("}"); + } + this._emitLine("frame = frame.pop();"); + }; + _proto._compileAsyncLoop = function _compileAsyncLoop(node, frame, parallel) { + var _this11 = this; + var i = this._tmpid(); + var len = this._tmpid(); + var arr = this._tmpid(); + var asyncMethod = parallel ? "asyncAll" : "asyncEach"; + frame = frame.push(); + this._emitLine("frame = frame.push();"); + this._emit("var " + arr + " = runtime.fromIterator("); + this._compileExpression(node.arr, frame); + this._emitLine(");"); + if (node.name instanceof nodes2.Array) { + var arrayLen = node.name.children.length; + this._emit("runtime." + asyncMethod + "(" + arr + ", " + arrayLen + ", function("); + node.name.children.forEach(function(name) { + _this11._emit(name.value + ","); + }); + this._emit(i + "," + len + ",next) {"); + node.name.children.forEach(function(name) { + var id2 = name.value; + frame.set(id2, id2); + _this11._emitLine('frame.set("' + id2 + '", ' + id2 + ");"); + }); + } else { + var id = node.name.value; + this._emitLine("runtime." + asyncMethod + "(" + arr + ", 1, function(" + id + ", " + i + ", " + len + ",next) {"); + this._emitLine('frame.set("' + id + '", ' + id + ");"); + frame.set(id, id); + } + this._emitLoopBindings(node, arr, i, len); + this._withScopedSyntax(function() { + var buf; + if (parallel) { + buf = _this11._pushBuffer(); + } + _this11.compile(node.body, frame); + _this11._emitLine("next(" + i + (buf ? "," + buf : "") + ");"); + if (parallel) { + _this11._popBuffer(); + } + }); + var output = this._tmpid(); + this._emitLine("}, " + this._makeCallback(output)); + this._addScopeLevel(); + if (parallel) { + this._emitLine(this.buffer + " += " + output + ";"); + } + if (node.else_) { + this._emitLine("if (!" + arr + ".length) {"); + this.compile(node.else_, frame); + this._emitLine("}"); + } + this._emitLine("frame = frame.pop();"); + }; + _proto.compileAsyncEach = function compileAsyncEach(node, frame) { + this._compileAsyncLoop(node, frame); + }; + _proto.compileAsyncAll = function compileAsyncAll(node, frame) { + this._compileAsyncLoop(node, frame, true); + }; + _proto._compileMacro = function _compileMacro(node, frame) { + var _this12 = this; + var args = []; + var kwargs = null; + var funcId = "macro_" + this._tmpid(); + var keepFrame = frame !== void 0; + node.args.children.forEach(function(arg, i) { + if (i === node.args.children.length - 1 && arg instanceof nodes2.Dict) { + kwargs = arg; + } else { + _this12.assertType(arg, nodes2.Symbol); + args.push(arg); + } + }); + var realNames = [].concat(args.map(function(n) { + return "l_" + n.value; + }), ["kwargs"]); + var argNames = args.map(function(n) { + return '"' + n.value + '"'; + }); + var kwargNames = (kwargs && kwargs.children || []).map(function(n) { + return '"' + n.key.value + '"'; + }); + var currFrame; + if (keepFrame) { + currFrame = frame.push(true); + } else { + currFrame = new Frame(); + } + this._emitLines("var " + funcId + " = runtime.makeMacro(", "[" + argNames.join(", ") + "], ", "[" + kwargNames.join(", ") + "], ", "function (" + realNames.join(", ") + ") {", "var callerFrame = frame;", "frame = " + (keepFrame ? "frame.push(true);" : "new runtime.Frame();"), "kwargs = kwargs || {};", 'if (Object.prototype.hasOwnProperty.call(kwargs, "caller")) {', 'frame.set("caller", kwargs.caller); }'); + args.forEach(function(arg) { + _this12._emitLine('frame.set("' + arg.value + '", l_' + arg.value + ");"); + currFrame.set(arg.value, "l_" + arg.value); + }); + if (kwargs) { + kwargs.children.forEach(function(pair) { + var name = pair.key.value; + _this12._emit('frame.set("' + name + '", '); + _this12._emit('Object.prototype.hasOwnProperty.call(kwargs, "' + name + '")'); + _this12._emit(' ? kwargs["' + name + '"] : '); + _this12._compileExpression(pair.value, currFrame); + _this12._emit(");"); + }); + } + var bufferId = this._pushBuffer(); + this._withScopedSyntax(function() { + _this12.compile(node.body, currFrame); + }); + this._emitLine("frame = " + (keepFrame ? "frame.pop();" : "callerFrame;")); + this._emitLine("return new runtime.SafeString(" + bufferId + ");"); + this._emitLine("});"); + this._popBuffer(); + return funcId; + }; + _proto.compileMacro = function compileMacro(node, frame) { + var funcId = this._compileMacro(node); + var name = node.name.value; + frame.set(name, funcId); + if (frame.parent) { + this._emitLine('frame.set("' + name + '", ' + funcId + ");"); + } else { + if (node.name.value.charAt(0) !== "_") { + this._emitLine('context.addExport("' + name + '");'); + } + this._emitLine('context.setVariable("' + name + '", ' + funcId + ");"); + } + }; + _proto.compileCaller = function compileCaller(node, frame) { + this._emit("(function (){"); + var funcId = this._compileMacro(node, frame); + this._emit("return " + funcId + ";})()"); + }; + _proto._compileGetTemplate = function _compileGetTemplate(node, frame, eagerCompile, ignoreMissing) { + var parentTemplateId = this._tmpid(); + var parentName = this._templateName(); + var cb = this._makeCallback(parentTemplateId); + var eagerCompileArg = eagerCompile ? "true" : "false"; + var ignoreMissingArg = ignoreMissing ? "true" : "false"; + this._emit("env.getTemplate("); + this._compileExpression(node.template, frame); + this._emitLine(", " + eagerCompileArg + ", " + parentName + ", " + ignoreMissingArg + ", " + cb); + return parentTemplateId; + }; + _proto.compileImport = function compileImport(node, frame) { + var target = node.target.value; + var id = this._compileGetTemplate(node, frame, false, false); + this._addScopeLevel(); + this._emitLine(id + ".getExported(" + (node.withContext ? "context.getVariables(), frame, " : "") + this._makeCallback(id)); + this._addScopeLevel(); + frame.set(target, id); + if (frame.parent) { + this._emitLine('frame.set("' + target + '", ' + id + ");"); + } else { + this._emitLine('context.setVariable("' + target + '", ' + id + ");"); + } + }; + _proto.compileFromImport = function compileFromImport(node, frame) { + var _this13 = this; + var importedId = this._compileGetTemplate(node, frame, false, false); + this._addScopeLevel(); + this._emitLine(importedId + ".getExported(" + (node.withContext ? "context.getVariables(), frame, " : "") + this._makeCallback(importedId)); + this._addScopeLevel(); + node.names.children.forEach(function(nameNode) { + var name; + var alias; + var id = _this13._tmpid(); + if (nameNode instanceof nodes2.Pair) { + name = nameNode.key.value; + alias = nameNode.value.value; + } else { + name = nameNode.value; + alias = name; + } + _this13._emitLine("if(Object.prototype.hasOwnProperty.call(" + importedId + ', "' + name + '")) {'); + _this13._emitLine("var " + id + " = " + importedId + "." + name + ";"); + _this13._emitLine("} else {"); + _this13._emitLine(`cb(new Error("cannot import '` + name + `'")); return;`); + _this13._emitLine("}"); + frame.set(alias, id); + if (frame.parent) { + _this13._emitLine('frame.set("' + alias + '", ' + id + ");"); + } else { + _this13._emitLine('context.setVariable("' + alias + '", ' + id + ");"); + } + }); + }; + _proto.compileBlock = function compileBlock(node) { + var id = this._tmpid(); + if (!this.inBlock) { + this._emit('(parentTemplate ? function(e, c, f, r, cb) { cb(""); } : '); + } + this._emit('context.getBlock("' + node.name.value + '")'); + if (!this.inBlock) { + this._emit(")"); + } + this._emitLine("(env, context, frame, runtime, " + this._makeCallback(id)); + this._emitLine(this.buffer + " += " + id + ";"); + this._addScopeLevel(); + }; + _proto.compileSuper = function compileSuper(node, frame) { + var name = node.blockName.value; + var id = node.symbol.value; + var cb = this._makeCallback(id); + this._emitLine('context.getSuper(env, "' + name + '", b_' + name + ", frame, runtime, " + cb); + this._emitLine(id + " = runtime.markSafe(" + id + ");"); + this._addScopeLevel(); + frame.set(id, id); + }; + _proto.compileExtends = function compileExtends(node, frame) { + var k = this._tmpid(); + var parentTemplateId = this._compileGetTemplate(node, frame, true, false); + this._emitLine("parentTemplate = " + parentTemplateId); + this._emitLine("for(var " + k + " in parentTemplate.blocks) {"); + this._emitLine("context.addBlock(" + k + ", parentTemplate.blocks[" + k + "]);"); + this._emitLine("}"); + this._addScopeLevel(); + }; + _proto.compileInclude = function compileInclude(node, frame) { + this._emitLine("var tasks = [];"); + this._emitLine("tasks.push("); + this._emitLine("function(callback) {"); + var id = this._compileGetTemplate(node, frame, false, node.ignoreMissing); + this._emitLine("callback(null," + id + ");});"); + this._emitLine("});"); + var id2 = this._tmpid(); + this._emitLine("tasks.push("); + this._emitLine("function(template, callback){"); + this._emitLine("template.render(context.getVariables(), frame, " + this._makeCallback(id2)); + this._emitLine("callback(null," + id2 + ");});"); + this._emitLine("});"); + this._emitLine("tasks.push("); + this._emitLine("function(result, callback){"); + this._emitLine(this.buffer + " += result;"); + this._emitLine("callback(null);"); + this._emitLine("});"); + this._emitLine("env.waterfall(tasks, function(){"); + this._addScopeLevel(); + }; + _proto.compileTemplateData = function compileTemplateData(node, frame) { + this.compileLiteral(node, frame); + }; + _proto.compileCapture = function compileCapture(node, frame) { + var _this14 = this; + var buffer = this.buffer; + this.buffer = "output"; + this._emitLine("(function() {"); + this._emitLine('var output = "";'); + this._withScopedSyntax(function() { + _this14.compile(node.body, frame); + }); + this._emitLine("return output;"); + this._emitLine("})()"); + this.buffer = buffer; + }; + _proto.compileOutput = function compileOutput(node, frame) { + var _this15 = this; + var children = node.children; + children.forEach(function(child) { + if (child instanceof nodes2.TemplateData) { + if (child.value) { + _this15._emit(_this15.buffer + " += "); + _this15.compileLiteral(child, frame); + _this15._emitLine(";"); + } + } else { + _this15._emit(_this15.buffer + " += runtime.suppressValue("); + if (_this15.throwOnUndefined) { + _this15._emit("runtime.ensureDefined("); + } + _this15.compile(child, frame); + if (_this15.throwOnUndefined) { + _this15._emit("," + node.lineno + "," + node.colno + ")"); + } + _this15._emit(", env.opts.autoescape);\n"); + } + }); + }; + _proto.compileRoot = function compileRoot(node, frame) { + var _this16 = this; + if (frame) { + this.fail("compileRoot: root node can't have frame"); + } + frame = new Frame(); + this._emitFuncBegin(node, "root"); + this._emitLine("var parentTemplate = null;"); + this._compileChildren(node, frame); + this._emitLine("if(parentTemplate) {"); + this._emitLine("parentTemplate.rootRenderFunc(env, context, frame, runtime, cb);"); + this._emitLine("} else {"); + this._emitLine("cb(null, " + this.buffer + ");"); + this._emitLine("}"); + this._emitFuncEnd(true); + this.inBlock = true; + var blockNames = []; + var blocks = node.findAll(nodes2.Block); + blocks.forEach(function(block, i) { + var name = block.name.value; + if (blockNames.indexOf(name) !== -1) { + throw new Error('Block "' + name + '" defined more than once.'); + } + blockNames.push(name); + _this16._emitFuncBegin(block, "b_" + name); + var tmpFrame = new Frame(); + _this16._emitLine("var frame = frame.push(true);"); + _this16.compile(block.body, tmpFrame); + _this16._emitFuncEnd(); + }); + this._emitLine("return {"); + blocks.forEach(function(block, i) { + var blockName = "b_" + block.name.value; + _this16._emitLine(blockName + ": " + blockName + ","); + }); + this._emitLine("root: root\n};"); + }; + _proto.compile = function compile2(node, frame) { + var _compile = this["compile" + node.typename]; + if (_compile) { + _compile.call(this, node, frame); + } else { + this.fail("compile: Cannot compile node: " + node.typename, node.lineno, node.colno); + } + }; + _proto.getCode = function getCode() { + return this.codebuf.join(""); + }; + return Compiler2; + }(Obj); + module2.exports = { + compile: function compile2(src, asyncFilters, extensions, name, opts) { + if (opts === void 0) { + opts = {}; + } + var c = new Compiler(name, opts.throwOnUndefined); + var preprocessors = (extensions || []).map(function(ext) { + return ext.preprocess; + }).filter(function(f) { + return !!f; + }); + var processedSrc = preprocessors.reduce(function(s, processor) { + return processor(s); + }, src); + c.compile(transformer.transform(parser2.parse(processedSrc, extensions, opts), asyncFilters, name)); + return c.getCode(); + }, + Compiler + }; +}); + +// ../../node_modules/nunjucks/src/filters.js +var require_filters = __commonJS((exports2, module2) => { + "use strict"; + var lib2 = require_lib(); + var r = require_runtime(); + var _exports = module2.exports = {}; + function normalize(value, defaultValue) { + if (value === null || value === void 0 || value === false) { + return defaultValue; + } + return value; + } + _exports.abs = Math.abs; + function isNaN2(num) { + return num !== num; + } + function batch(arr, linecount, fillWith) { + var i; + var res = []; + var tmp = []; + for (i = 0; i < arr.length; i++) { + if (i % linecount === 0 && tmp.length) { + res.push(tmp); + tmp = []; + } + tmp.push(arr[i]); + } + if (tmp.length) { + if (fillWith) { + for (i = tmp.length; i < linecount; i++) { + tmp.push(fillWith); + } + } + res.push(tmp); + } + return res; + } + _exports.batch = batch; + function capitalize(str) { + str = normalize(str, ""); + var ret = str.toLowerCase(); + return r.copySafeness(str, ret.charAt(0).toUpperCase() + ret.slice(1)); + } + _exports.capitalize = capitalize; + function center(str, width) { + str = normalize(str, ""); + width = width || 80; + if (str.length >= width) { + return str; + } + var spaces = width - str.length; + var pre = lib2.repeat(" ", spaces / 2 - spaces % 2); + var post = lib2.repeat(" ", spaces / 2); + return r.copySafeness(str, pre + str + post); + } + _exports.center = center; + function default_(val, def, bool) { + if (bool) { + return val || def; + } else { + return val !== void 0 ? val : def; + } + } + _exports["default"] = default_; + function dictsort(val, caseSensitive, by) { + if (!lib2.isObject(val)) { + throw new lib2.TemplateError("dictsort filter: val must be an object"); + } + var array = []; + for (var k in val) { + array.push([k, val[k]]); + } + var si; + if (by === void 0 || by === "key") { + si = 0; + } else if (by === "value") { + si = 1; + } else { + throw new lib2.TemplateError("dictsort filter: You can only sort by either key or value"); + } + array.sort(function(t1, t2) { + var a = t1[si]; + var b = t2[si]; + if (!caseSensitive) { + if (lib2.isString(a)) { + a = a.toUpperCase(); + } + if (lib2.isString(b)) { + b = b.toUpperCase(); + } + } + return a > b ? 1 : a === b ? 0 : -1; + }); + return array; + } + _exports.dictsort = dictsort; + function dump(obj, spaces) { + return JSON.stringify(obj, null, spaces); + } + _exports.dump = dump; + function escape(str) { + if (str instanceof r.SafeString) { + return str; + } + str = str === null || str === void 0 ? "" : str; + return r.markSafe(lib2.escape(str.toString())); + } + _exports.escape = escape; + function safe(str) { + if (str instanceof r.SafeString) { + return str; + } + str = str === null || str === void 0 ? "" : str; + return r.markSafe(str.toString()); + } + _exports.safe = safe; + function first(arr) { + return arr[0]; + } + _exports.first = first; + function forceescape(str) { + str = str === null || str === void 0 ? "" : str; + return r.markSafe(lib2.escape(str.toString())); + } + _exports.forceescape = forceescape; + function groupby(arr, attr) { + return lib2.groupBy(arr, attr, this.env.opts.throwOnUndefined); + } + _exports.groupby = groupby; + function indent(str, width, indentfirst) { + str = normalize(str, ""); + if (str === "") { + return ""; + } + width = width || 4; + var lines = str.split("\n"); + var sp = lib2.repeat(" ", width); + var res = lines.map(function(l, i) { + return i === 0 && !indentfirst ? l : "" + sp + l; + }).join("\n"); + return r.copySafeness(str, res); + } + _exports.indent = indent; + function join(arr, del, attr) { + del = del || ""; + if (attr) { + arr = lib2.map(arr, function(v) { + return v[attr]; + }); + } + return arr.join(del); + } + _exports.join = join; + function last(arr) { + return arr[arr.length - 1]; + } + _exports.last = last; + function lengthFilter(val) { + var value = normalize(val, ""); + if (value !== void 0) { + if (typeof Map === "function" && value instanceof Map || typeof Set === "function" && value instanceof Set) { + return value.size; + } + if (lib2.isObject(value) && !(value instanceof r.SafeString)) { + return lib2.keys(value).length; + } + return value.length; + } + return 0; + } + _exports.length = lengthFilter; + function list(val) { + if (lib2.isString(val)) { + return val.split(""); + } else if (lib2.isObject(val)) { + return lib2._entries(val || {}).map(function(_ref) { + var key = _ref[0], value = _ref[1]; + return { + key, + value + }; + }); + } else if (lib2.isArray(val)) { + return val; + } else { + throw new lib2.TemplateError("list filter: type not iterable"); + } + } + _exports.list = list; + function lower(str) { + str = normalize(str, ""); + return str.toLowerCase(); + } + _exports.lower = lower; + function nl2br(str) { + if (str === null || str === void 0) { + return ""; + } + return r.copySafeness(str, str.replace(/\r\n|\n/g, "
\n")); + } + _exports.nl2br = nl2br; + function random(arr) { + return arr[Math.floor(Math.random() * arr.length)]; + } + _exports.random = random; + function getSelectOrReject(expectedTestResult) { + function filter(arr, testName, secondArg) { + if (testName === void 0) { + testName = "truthy"; + } + var context = this; + var test = context.env.getTest(testName); + return lib2.toArray(arr).filter(function examineTestResult(item) { + return test.call(context, item, secondArg) === expectedTestResult; + }); + } + return filter; + } + _exports.reject = getSelectOrReject(false); + function rejectattr(arr, attr) { + return arr.filter(function(item) { + return !item[attr]; + }); + } + _exports.rejectattr = rejectattr; + _exports.select = getSelectOrReject(true); + function selectattr(arr, attr) { + return arr.filter(function(item) { + return !!item[attr]; + }); + } + _exports.selectattr = selectattr; + function replace(str, old, new_, maxCount) { + var originalStr = str; + if (old instanceof RegExp) { + return str.replace(old, new_); + } + if (typeof maxCount === "undefined") { + maxCount = -1; + } + var res = ""; + if (typeof old === "number") { + old = "" + old; + } else if (typeof old !== "string") { + return str; + } + if (typeof str === "number") { + str = "" + str; + } + if (typeof str !== "string" && !(str instanceof r.SafeString)) { + return str; + } + if (old === "") { + res = new_ + str.split("").join(new_) + new_; + return r.copySafeness(str, res); + } + var nextIndex = str.indexOf(old); + if (maxCount === 0 || nextIndex === -1) { + return str; + } + var pos = 0; + var count = 0; + while (nextIndex > -1 && (maxCount === -1 || count < maxCount)) { + res += str.substring(pos, nextIndex) + new_; + pos = nextIndex + old.length; + count++; + nextIndex = str.indexOf(old, pos); + } + if (pos < str.length) { + res += str.substring(pos); + } + return r.copySafeness(originalStr, res); + } + _exports.replace = replace; + function reverse(val) { + var arr; + if (lib2.isString(val)) { + arr = list(val); + } else { + arr = lib2.map(val, function(v) { + return v; + }); + } + arr.reverse(); + if (lib2.isString(val)) { + return r.copySafeness(val, arr.join("")); + } + return arr; + } + _exports.reverse = reverse; + function round(val, precision, method) { + precision = precision || 0; + var factor = Math.pow(10, precision); + var rounder; + if (method === "ceil") { + rounder = Math.ceil; + } else if (method === "floor") { + rounder = Math.floor; + } else { + rounder = Math.round; + } + return rounder(val * factor) / factor; + } + _exports.round = round; + function slice(arr, slices, fillWith) { + var sliceLength = Math.floor(arr.length / slices); + var extra = arr.length % slices; + var res = []; + var offset = 0; + for (var i = 0; i < slices; i++) { + var start = offset + i * sliceLength; + if (i < extra) { + offset++; + } + var end = offset + (i + 1) * sliceLength; + var currSlice = arr.slice(start, end); + if (fillWith && i >= extra) { + currSlice.push(fillWith); + } + res.push(currSlice); + } + return res; + } + _exports.slice = slice; + function sum(arr, attr, start) { + if (start === void 0) { + start = 0; + } + if (attr) { + arr = lib2.map(arr, function(v) { + return v[attr]; + }); + } + return start + arr.reduce(function(a, b) { + return a + b; + }, 0); + } + _exports.sum = sum; + _exports.sort = r.makeMacro(["value", "reverse", "case_sensitive", "attribute"], [], function sortFilter(arr, reversed, caseSens, attr) { + var _this = this; + var array = lib2.map(arr, function(v) { + return v; + }); + var getAttribute = lib2.getAttrGetter(attr); + array.sort(function(a, b) { + var x = attr ? getAttribute(a) : a; + var y = attr ? getAttribute(b) : b; + if (_this.env.opts.throwOnUndefined && attr && (x === void 0 || y === void 0)) { + throw new TypeError('sort: attribute "' + attr + '" resolved to undefined'); + } + if (!caseSens && lib2.isString(x) && lib2.isString(y)) { + x = x.toLowerCase(); + y = y.toLowerCase(); + } + if (x < y) { + return reversed ? 1 : -1; + } else if (x > y) { + return reversed ? -1 : 1; + } else { + return 0; + } + }); + return array; + }); + function string(obj) { + return r.copySafeness(obj, obj); + } + _exports.string = string; + function striptags(input, preserveLinebreaks) { + input = normalize(input, ""); + var tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>|/gi; + var trimmedInput = trim(input.replace(tags, "")); + var res = ""; + if (preserveLinebreaks) { + res = trimmedInput.replace(/^ +| +$/gm, "").replace(/ +/g, " ").replace(/(\r\n)/g, "\n").replace(/\n\n\n+/g, "\n\n"); + } else { + res = trimmedInput.replace(/\s+/gi, " "); + } + return r.copySafeness(input, res); + } + _exports.striptags = striptags; + function title(str) { + str = normalize(str, ""); + var words = str.split(" ").map(function(word) { + return capitalize(word); + }); + return r.copySafeness(str, words.join(" ")); + } + _exports.title = title; + function trim(str) { + return r.copySafeness(str, str.replace(/^\s*|\s*$/g, "")); + } + _exports.trim = trim; + function truncate(input, length, killwords, end) { + var orig = input; + input = normalize(input, ""); + length = length || 255; + if (input.length <= length) { + return input; + } + if (killwords) { + input = input.substring(0, length); + } else { + var idx = input.lastIndexOf(" ", length); + if (idx === -1) { + idx = length; + } + input = input.substring(0, idx); + } + input += end !== void 0 && end !== null ? end : "..."; + return r.copySafeness(orig, input); + } + _exports.truncate = truncate; + function upper(str) { + str = normalize(str, ""); + return str.toUpperCase(); + } + _exports.upper = upper; + function urlencode(obj) { + var enc = encodeURIComponent; + if (lib2.isString(obj)) { + return enc(obj); + } else { + var keyvals = lib2.isArray(obj) ? obj : lib2._entries(obj); + return keyvals.map(function(_ref2) { + var k = _ref2[0], v = _ref2[1]; + return enc(k) + "=" + enc(v); + }).join("&"); + } + } + _exports.urlencode = urlencode; + var puncRe = /^(?:\(|<|<)?(.*?)(?:\.|,|\)|\n|>)?$/; + var emailRe = /^[\w.!#$%&'*+\-\/=?\^`{|}~]+@[a-z\d\-]+(\.[a-z\d\-]+)+$/i; + var httpHttpsRe = /^https?:\/\/.*$/; + var wwwRe = /^www\./; + var tldRe = /\.(?:org|net|com)(?:\:|\/|$)/; + function urlize(str, length, nofollow) { + if (isNaN2(length)) { + length = Infinity; + } + var noFollowAttr = nofollow === true ? ' rel="nofollow"' : ""; + var words = str.split(/(\s+)/).filter(function(word) { + return word && word.length; + }).map(function(word) { + var matches = word.match(puncRe); + var possibleUrl = matches ? matches[1] : word; + var shortUrl = possibleUrl.substr(0, length); + if (httpHttpsRe.test(possibleUrl)) { + return '" + shortUrl + ""; + } + if (wwwRe.test(possibleUrl)) { + return '" + shortUrl + ""; + } + if (emailRe.test(possibleUrl)) { + return '' + possibleUrl + ""; + } + if (tldRe.test(possibleUrl)) { + return '" + shortUrl + ""; + } + return word; + }); + return words.join(""); + } + _exports.urlize = urlize; + function wordcount(str) { + str = normalize(str, ""); + var words = str ? str.match(/\w+/g) : null; + return words ? words.length : null; + } + _exports.wordcount = wordcount; + function float(val, def) { + var res = parseFloat(val); + return isNaN2(res) ? def : res; + } + _exports.float = float; + var intFilter = r.makeMacro(["value", "default", "base"], [], function doInt(value, defaultValue, base) { + if (base === void 0) { + base = 10; + } + var res = parseInt(value, base); + return isNaN2(res) ? defaultValue : res; + }); + _exports.int = intFilter; + _exports.d = _exports.default; + _exports.e = _exports.escape; +}); + +// ../../node_modules/nunjucks/src/loader.js +var require_loader = __commonJS((exports2, module2) => { + "use strict"; + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + _setPrototypeOf(subClass, superClass); + } + function _setPrototypeOf(o, p) { + _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf2(o2, p2) { + o2.__proto__ = p2; + return o2; + }; + return _setPrototypeOf(o, p); + } + var path = require("path"); + var _require2 = require_object(); + var EmitterObj = _require2.EmitterObj; + module2.exports = /* @__PURE__ */ function(_EmitterObj) { + _inheritsLoose(Loader2, _EmitterObj); + function Loader2() { + return _EmitterObj.apply(this, arguments) || this; + } + var _proto = Loader2.prototype; + _proto.resolve = function resolve(from, to) { + return path.resolve(path.dirname(from), to); + }; + _proto.isRelative = function isRelative(filename) { + return filename.indexOf("./") === 0 || filename.indexOf("../") === 0; + }; + return Loader2; + }(EmitterObj); +}); + +// ../../node_modules/nunjucks/src/precompiled-loader.js +var require_precompiled_loader = __commonJS((exports2, module2) => { + "use strict"; + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + _setPrototypeOf(subClass, superClass); + } + function _setPrototypeOf(o, p) { + _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf2(o2, p2) { + o2.__proto__ = p2; + return o2; + }; + return _setPrototypeOf(o, p); + } + var Loader2 = require_loader(); + var PrecompiledLoader = /* @__PURE__ */ function(_Loader) { + _inheritsLoose(PrecompiledLoader2, _Loader); + function PrecompiledLoader2(compiledTemplates) { + var _this; + _this = _Loader.call(this) || this; + _this.precompiled = compiledTemplates || {}; + return _this; + } + var _proto = PrecompiledLoader2.prototype; + _proto.getSource = function getSource(name) { + if (this.precompiled[name]) { + return { + src: { + type: "code", + obj: this.precompiled[name] + }, + path: name + }; + } + return null; + }; + return PrecompiledLoader2; + }(Loader2); + module2.exports = { + PrecompiledLoader + }; +}); + +// ../../node_modules/picomatch/lib/constants.js +var require_constants = __commonJS((exports2, module2) => { + "use strict"; + var path = require("path"); + var WIN_SLASH = "\\\\/"; + var WIN_NO_SLASH = `[^${WIN_SLASH}]`; + var DOT_LITERAL = "\\."; + var PLUS_LITERAL = "\\+"; + var QMARK_LITERAL = "\\?"; + var SLASH_LITERAL = "\\/"; + var ONE_CHAR = "(?=.)"; + var QMARK = "[^/]"; + var END_ANCHOR = `(?:${SLASH_LITERAL}|$)`; + var START_ANCHOR = `(?:^|${SLASH_LITERAL})`; + var DOTS_SLASH = `${DOT_LITERAL}{1,2}${END_ANCHOR}`; + var NO_DOT = `(?!${DOT_LITERAL})`; + var NO_DOTS = `(?!${START_ANCHOR}${DOTS_SLASH})`; + var NO_DOT_SLASH = `(?!${DOT_LITERAL}{0,1}${END_ANCHOR})`; + var NO_DOTS_SLASH = `(?!${DOTS_SLASH})`; + var QMARK_NO_DOT = `[^.${SLASH_LITERAL}]`; + var STAR = `${QMARK}*?`; + var POSIX_CHARS = { + DOT_LITERAL, + PLUS_LITERAL, + QMARK_LITERAL, + SLASH_LITERAL, + ONE_CHAR, + QMARK, + END_ANCHOR, + DOTS_SLASH, + NO_DOT, + NO_DOTS, + NO_DOT_SLASH, + NO_DOTS_SLASH, + QMARK_NO_DOT, + STAR, + START_ANCHOR + }; + var WINDOWS_CHARS = { + ...POSIX_CHARS, + SLASH_LITERAL: `[${WIN_SLASH}]`, + QMARK: WIN_NO_SLASH, + STAR: `${WIN_NO_SLASH}*?`, + DOTS_SLASH: `${DOT_LITERAL}{1,2}(?:[${WIN_SLASH}]|$)`, + NO_DOT: `(?!${DOT_LITERAL})`, + NO_DOTS: `(?!(?:^|[${WIN_SLASH}])${DOT_LITERAL}{1,2}(?:[${WIN_SLASH}]|$))`, + NO_DOT_SLASH: `(?!${DOT_LITERAL}{0,1}(?:[${WIN_SLASH}]|$))`, + NO_DOTS_SLASH: `(?!${DOT_LITERAL}{1,2}(?:[${WIN_SLASH}]|$))`, + QMARK_NO_DOT: `[^.${WIN_SLASH}]`, + START_ANCHOR: `(?:^|[${WIN_SLASH}])`, + END_ANCHOR: `(?:[${WIN_SLASH}]|$)` + }; + var POSIX_REGEX_SOURCE = { + alnum: "a-zA-Z0-9", + alpha: "a-zA-Z", + ascii: "\\x00-\\x7F", + blank: " \\t", + cntrl: "\\x00-\\x1F\\x7F", + digit: "0-9", + graph: "\\x21-\\x7E", + lower: "a-z", + print: "\\x20-\\x7E ", + punct: "\\-!\"#$%&'()\\*+,./:;<=>?@[\\]^_`{|}~", + space: " \\t\\r\\n\\v\\f", + upper: "A-Z", + word: "A-Za-z0-9_", + xdigit: "A-Fa-f0-9" + }; + module2.exports = { + MAX_LENGTH: 1024 * 64, + POSIX_REGEX_SOURCE, + REGEX_BACKSLASH: /\\(?![*+?^${}(|)[\]])/g, + REGEX_NON_SPECIAL_CHARS: /^[^@![\].,$*+?^{}()|\\/]+/, + REGEX_SPECIAL_CHARS: /[-*+?.^${}(|)[\]]/, + REGEX_SPECIAL_CHARS_BACKREF: /(\\?)((\W)(\3*))/g, + REGEX_SPECIAL_CHARS_GLOBAL: /([-*+?.^${}(|)[\]])/g, + REGEX_REMOVE_BACKSLASH: /(?:\[.*?[^\\]\]|\\(?=.))/g, + REPLACEMENTS: { + "***": "*", + "**/**": "**", + "**/**/**": "**" + }, + CHAR_0: 48, + CHAR_9: 57, + CHAR_UPPERCASE_A: 65, + CHAR_LOWERCASE_A: 97, + CHAR_UPPERCASE_Z: 90, + CHAR_LOWERCASE_Z: 122, + CHAR_LEFT_PARENTHESES: 40, + CHAR_RIGHT_PARENTHESES: 41, + CHAR_ASTERISK: 42, + CHAR_AMPERSAND: 38, + CHAR_AT: 64, + CHAR_BACKWARD_SLASH: 92, + CHAR_CARRIAGE_RETURN: 13, + CHAR_CIRCUMFLEX_ACCENT: 94, + CHAR_COLON: 58, + CHAR_COMMA: 44, + CHAR_DOT: 46, + CHAR_DOUBLE_QUOTE: 34, + CHAR_EQUAL: 61, + CHAR_EXCLAMATION_MARK: 33, + CHAR_FORM_FEED: 12, + CHAR_FORWARD_SLASH: 47, + CHAR_GRAVE_ACCENT: 96, + CHAR_HASH: 35, + CHAR_HYPHEN_MINUS: 45, + CHAR_LEFT_ANGLE_BRACKET: 60, + CHAR_LEFT_CURLY_BRACE: 123, + CHAR_LEFT_SQUARE_BRACKET: 91, + CHAR_LINE_FEED: 10, + CHAR_NO_BREAK_SPACE: 160, + CHAR_PERCENT: 37, + CHAR_PLUS: 43, + CHAR_QUESTION_MARK: 63, + CHAR_RIGHT_ANGLE_BRACKET: 62, + CHAR_RIGHT_CURLY_BRACE: 125, + CHAR_RIGHT_SQUARE_BRACKET: 93, + CHAR_SEMICOLON: 59, + CHAR_SINGLE_QUOTE: 39, + CHAR_SPACE: 32, + CHAR_TAB: 9, + CHAR_UNDERSCORE: 95, + CHAR_VERTICAL_LINE: 124, + CHAR_ZERO_WIDTH_NOBREAK_SPACE: 65279, + SEP: path.sep, + extglobChars(chars) { + return { + "!": {type: "negate", open: "(?:(?!(?:", close: `))${chars.STAR})`}, + "?": {type: "qmark", open: "(?:", close: ")?"}, + "+": {type: "plus", open: "(?:", close: ")+"}, + "*": {type: "star", open: "(?:", close: ")*"}, + "@": {type: "at", open: "(?:", close: ")"} + }; + }, + globChars(win32) { + return win32 === true ? WINDOWS_CHARS : POSIX_CHARS; + } + }; +}); + +// ../../node_modules/picomatch/lib/utils.js +var require_utils = __commonJS((exports2) => { + "use strict"; + var path = require("path"); + var win32 = process.platform === "win32"; + var { + REGEX_BACKSLASH, + REGEX_REMOVE_BACKSLASH, + REGEX_SPECIAL_CHARS, + REGEX_SPECIAL_CHARS_GLOBAL + } = require_constants(); + exports2.isObject = (val) => val !== null && typeof val === "object" && !Array.isArray(val); + exports2.hasRegexChars = (str) => REGEX_SPECIAL_CHARS.test(str); + exports2.isRegexChar = (str) => str.length === 1 && exports2.hasRegexChars(str); + exports2.escapeRegex = (str) => str.replace(REGEX_SPECIAL_CHARS_GLOBAL, "\\$1"); + exports2.toPosixSlashes = (str) => str.replace(REGEX_BACKSLASH, "/"); + exports2.removeBackslashes = (str) => { + return str.replace(REGEX_REMOVE_BACKSLASH, (match) => { + return match === "\\" ? "" : match; + }); + }; + exports2.supportsLookbehinds = () => { + const segs = process.version.slice(1).split(".").map(Number); + if (segs.length === 3 && segs[0] >= 9 || segs[0] === 8 && segs[1] >= 10) { + return true; + } + return false; + }; + exports2.isWindows = (options) => { + if (options && typeof options.windows === "boolean") { + return options.windows; + } + return win32 === true || path.sep === "\\"; + }; + exports2.escapeLast = (input, char, lastIdx) => { + const idx = input.lastIndexOf(char, lastIdx); + if (idx === -1) + return input; + if (input[idx - 1] === "\\") + return exports2.escapeLast(input, char, idx - 1); + return `${input.slice(0, idx)}\\${input.slice(idx)}`; + }; + exports2.removePrefix = (input, state = {}) => { + let output = input; + if (output.startsWith("./")) { + output = output.slice(2); + state.prefix = "./"; + } + return output; + }; + exports2.wrapOutput = (input, state = {}, options = {}) => { + const prepend = options.contains ? "" : "^"; + const append = options.contains ? "" : "$"; + let output = `${prepend}(?:${input})${append}`; + if (state.negated === true) { + output = `(?:^(?!${output}).*$)`; + } + return output; + }; +}); + +// ../../node_modules/picomatch/lib/scan.js +var require_scan = __commonJS((exports2, module2) => { + "use strict"; + var utils = require_utils(); + var { + CHAR_ASTERISK, + CHAR_AT, + CHAR_BACKWARD_SLASH, + CHAR_COMMA, + CHAR_DOT, + CHAR_EXCLAMATION_MARK, + CHAR_FORWARD_SLASH, + CHAR_LEFT_CURLY_BRACE, + CHAR_LEFT_PARENTHESES, + CHAR_LEFT_SQUARE_BRACKET, + CHAR_PLUS, + CHAR_QUESTION_MARK, + CHAR_RIGHT_CURLY_BRACE, + CHAR_RIGHT_PARENTHESES, + CHAR_RIGHT_SQUARE_BRACKET + } = require_constants(); + var isPathSeparator = (code) => { + return code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH; + }; + var depth = (token) => { + if (token.isPrefix !== true) { + token.depth = token.isGlobstar ? Infinity : 1; + } + }; + var scan = (input, options) => { + const opts = options || {}; + const length = input.length - 1; + const scanToEnd = opts.parts === true || opts.scanToEnd === true; + const slashes = []; + const tokens = []; + const parts = []; + let str = input; + let index = -1; + let start = 0; + let lastIndex = 0; + let isBrace = false; + let isBracket = false; + let isGlob = false; + let isExtglob = false; + let isGlobstar = false; + let braceEscaped = false; + let backslashes = false; + let negated = false; + let negatedExtglob = false; + let finished = false; + let braces = 0; + let prev; + let code; + let token = {value: "", depth: 0, isGlob: false}; + const eos = () => index >= length; + const peek = () => str.charCodeAt(index + 1); + const advance = () => { + prev = code; + return str.charCodeAt(++index); + }; + while (index < length) { + code = advance(); + let next; + if (code === CHAR_BACKWARD_SLASH) { + backslashes = token.backslashes = true; + code = advance(); + if (code === CHAR_LEFT_CURLY_BRACE) { + braceEscaped = true; + } + continue; + } + if (braceEscaped === true || code === CHAR_LEFT_CURLY_BRACE) { + braces++; + while (eos() !== true && (code = advance())) { + if (code === CHAR_BACKWARD_SLASH) { + backslashes = token.backslashes = true; + advance(); + continue; + } + if (code === CHAR_LEFT_CURLY_BRACE) { + braces++; + continue; + } + if (braceEscaped !== true && code === CHAR_DOT && (code = advance()) === CHAR_DOT) { + isBrace = token.isBrace = true; + isGlob = token.isGlob = true; + finished = true; + if (scanToEnd === true) { + continue; + } + break; + } + if (braceEscaped !== true && code === CHAR_COMMA) { + isBrace = token.isBrace = true; + isGlob = token.isGlob = true; + finished = true; + if (scanToEnd === true) { + continue; + } + break; + } + if (code === CHAR_RIGHT_CURLY_BRACE) { + braces--; + if (braces === 0) { + braceEscaped = false; + isBrace = token.isBrace = true; + finished = true; + break; + } + } + } + if (scanToEnd === true) { + continue; + } + break; + } + if (code === CHAR_FORWARD_SLASH) { + slashes.push(index); + tokens.push(token); + token = {value: "", depth: 0, isGlob: false}; + if (finished === true) + continue; + if (prev === CHAR_DOT && index === start + 1) { + start += 2; + continue; + } + lastIndex = index + 1; + continue; + } + if (opts.noext !== true) { + const isExtglobChar = code === CHAR_PLUS || code === CHAR_AT || code === CHAR_ASTERISK || code === CHAR_QUESTION_MARK || code === CHAR_EXCLAMATION_MARK; + if (isExtglobChar === true && peek() === CHAR_LEFT_PARENTHESES) { + isGlob = token.isGlob = true; + isExtglob = token.isExtglob = true; + finished = true; + if (code === CHAR_EXCLAMATION_MARK && index === start) { + negatedExtglob = true; + } + if (scanToEnd === true) { + while (eos() !== true && (code = advance())) { + if (code === CHAR_BACKWARD_SLASH) { + backslashes = token.backslashes = true; + code = advance(); + continue; + } + if (code === CHAR_RIGHT_PARENTHESES) { + isGlob = token.isGlob = true; + finished = true; + break; + } + } + continue; + } + break; + } + } + if (code === CHAR_ASTERISK) { + if (prev === CHAR_ASTERISK) + isGlobstar = token.isGlobstar = true; + isGlob = token.isGlob = true; + finished = true; + if (scanToEnd === true) { + continue; + } + break; + } + if (code === CHAR_QUESTION_MARK) { + isGlob = token.isGlob = true; + finished = true; + if (scanToEnd === true) { + continue; + } + break; + } + if (code === CHAR_LEFT_SQUARE_BRACKET) { + while (eos() !== true && (next = advance())) { + if (next === CHAR_BACKWARD_SLASH) { + backslashes = token.backslashes = true; + advance(); + continue; + } + if (next === CHAR_RIGHT_SQUARE_BRACKET) { + isBracket = token.isBracket = true; + isGlob = token.isGlob = true; + finished = true; + break; + } + } + if (scanToEnd === true) { + continue; + } + break; + } + if (opts.nonegate !== true && code === CHAR_EXCLAMATION_MARK && index === start) { + negated = token.negated = true; + start++; + continue; + } + if (opts.noparen !== true && code === CHAR_LEFT_PARENTHESES) { + isGlob = token.isGlob = true; + if (scanToEnd === true) { + while (eos() !== true && (code = advance())) { + if (code === CHAR_LEFT_PARENTHESES) { + backslashes = token.backslashes = true; + code = advance(); + continue; + } + if (code === CHAR_RIGHT_PARENTHESES) { + finished = true; + break; + } + } + continue; + } + break; + } + if (isGlob === true) { + finished = true; + if (scanToEnd === true) { + continue; + } + break; + } + } + if (opts.noext === true) { + isExtglob = false; + isGlob = false; + } + let base = str; + let prefix = ""; + let glob = ""; + if (start > 0) { + prefix = str.slice(0, start); + str = str.slice(start); + lastIndex -= start; + } + if (base && isGlob === true && lastIndex > 0) { + base = str.slice(0, lastIndex); + glob = str.slice(lastIndex); + } else if (isGlob === true) { + base = ""; + glob = str; + } else { + base = str; + } + if (base && base !== "" && base !== "/" && base !== str) { + if (isPathSeparator(base.charCodeAt(base.length - 1))) { + base = base.slice(0, -1); + } + } + if (opts.unescape === true) { + if (glob) + glob = utils.removeBackslashes(glob); + if (base && backslashes === true) { + base = utils.removeBackslashes(base); + } + } + const state = { + prefix, + input, + start, + base, + glob, + isBrace, + isBracket, + isGlob, + isExtglob, + isGlobstar, + negated, + negatedExtglob + }; + if (opts.tokens === true) { + state.maxDepth = 0; + if (!isPathSeparator(code)) { + tokens.push(token); + } + state.tokens = tokens; + } + if (opts.parts === true || opts.tokens === true) { + let prevIndex; + for (let idx = 0; idx < slashes.length; idx++) { + const n = prevIndex ? prevIndex + 1 : start; + const i = slashes[idx]; + const value = input.slice(n, i); + if (opts.tokens) { + if (idx === 0 && start !== 0) { + tokens[idx].isPrefix = true; + tokens[idx].value = prefix; + } else { + tokens[idx].value = value; + } + depth(tokens[idx]); + state.maxDepth += tokens[idx].depth; + } + if (idx !== 0 || value !== "") { + parts.push(value); + } + prevIndex = i; + } + if (prevIndex && prevIndex + 1 < input.length) { + const value = input.slice(prevIndex + 1); + parts.push(value); + if (opts.tokens) { + tokens[tokens.length - 1].value = value; + depth(tokens[tokens.length - 1]); + state.maxDepth += tokens[tokens.length - 1].depth; + } + } + state.slashes = slashes; + state.parts = parts; + } + return state; + }; + module2.exports = scan; +}); + +// ../../node_modules/picomatch/lib/parse.js +var require_parse = __commonJS((exports2, module2) => { + "use strict"; + var constants = require_constants(); + var utils = require_utils(); + var { + MAX_LENGTH, + POSIX_REGEX_SOURCE, + REGEX_NON_SPECIAL_CHARS, + REGEX_SPECIAL_CHARS_BACKREF, + REPLACEMENTS + } = constants; + var expandRange = (args, options) => { + if (typeof options.expandRange === "function") { + return options.expandRange(...args, options); + } + args.sort(); + const value = `[${args.join("-")}]`; + try { + new RegExp(value); + } catch (ex) { + return args.map((v) => utils.escapeRegex(v)).join(".."); + } + return value; + }; + var syntaxError = (type, char) => { + return `Missing ${type}: "${char}" - use "\\\\${char}" to match literal characters`; + }; + var parse = (input, options) => { + if (typeof input !== "string") { + throw new TypeError("Expected a string"); + } + input = REPLACEMENTS[input] || input; + const opts = {...options}; + const max = typeof opts.maxLength === "number" ? Math.min(MAX_LENGTH, opts.maxLength) : MAX_LENGTH; + let len = input.length; + if (len > max) { + throw new SyntaxError(`Input length: ${len}, exceeds maximum allowed length: ${max}`); + } + const bos = {type: "bos", value: "", output: opts.prepend || ""}; + const tokens = [bos]; + const capture = opts.capture ? "" : "?:"; + const win32 = utils.isWindows(options); + const PLATFORM_CHARS = constants.globChars(win32); + const EXTGLOB_CHARS = constants.extglobChars(PLATFORM_CHARS); + const { + DOT_LITERAL, + PLUS_LITERAL, + SLASH_LITERAL, + ONE_CHAR, + DOTS_SLASH, + NO_DOT, + NO_DOT_SLASH, + NO_DOTS_SLASH, + QMARK, + QMARK_NO_DOT, + STAR, + START_ANCHOR + } = PLATFORM_CHARS; + const globstar = (opts2) => { + return `(${capture}(?:(?!${START_ANCHOR}${opts2.dot ? DOTS_SLASH : DOT_LITERAL}).)*?)`; + }; + const nodot = opts.dot ? "" : NO_DOT; + const qmarkNoDot = opts.dot ? QMARK : QMARK_NO_DOT; + let star = opts.bash === true ? globstar(opts) : STAR; + if (opts.capture) { + star = `(${star})`; + } + if (typeof opts.noext === "boolean") { + opts.noextglob = opts.noext; + } + const state = { + input, + index: -1, + start: 0, + dot: opts.dot === true, + consumed: "", + output: "", + prefix: "", + backtrack: false, + negated: false, + brackets: 0, + braces: 0, + parens: 0, + quotes: 0, + globstar: false, + tokens + }; + input = utils.removePrefix(input, state); + len = input.length; + const extglobs = []; + const braces = []; + const stack = []; + let prev = bos; + let value; + const eos = () => state.index === len - 1; + const peek = state.peek = (n = 1) => input[state.index + n]; + const advance = state.advance = () => input[++state.index] || ""; + const remaining = () => input.slice(state.index + 1); + const consume = (value2 = "", num = 0) => { + state.consumed += value2; + state.index += num; + }; + const append = (token) => { + state.output += token.output != null ? token.output : token.value; + consume(token.value); + }; + const negate = () => { + let count = 1; + while (peek() === "!" && (peek(2) !== "(" || peek(3) === "?")) { + advance(); + state.start++; + count++; + } + if (count % 2 === 0) { + return false; + } + state.negated = true; + state.start++; + return true; + }; + const increment = (type) => { + state[type]++; + stack.push(type); + }; + const decrement = (type) => { + state[type]--; + stack.pop(); + }; + const push = (tok) => { + if (prev.type === "globstar") { + const isBrace = state.braces > 0 && (tok.type === "comma" || tok.type === "brace"); + const isExtglob = tok.extglob === true || extglobs.length && (tok.type === "pipe" || tok.type === "paren"); + if (tok.type !== "slash" && tok.type !== "paren" && !isBrace && !isExtglob) { + state.output = state.output.slice(0, -prev.output.length); + prev.type = "star"; + prev.value = "*"; + prev.output = star; + state.output += prev.output; + } + } + if (extglobs.length && tok.type !== "paren") { + extglobs[extglobs.length - 1].inner += tok.value; + } + if (tok.value || tok.output) + append(tok); + if (prev && prev.type === "text" && tok.type === "text") { + prev.value += tok.value; + prev.output = (prev.output || "") + tok.value; + return; + } + tok.prev = prev; + tokens.push(tok); + prev = tok; + }; + const extglobOpen = (type, value2) => { + const token = {...EXTGLOB_CHARS[value2], conditions: 1, inner: ""}; + token.prev = prev; + token.parens = state.parens; + token.output = state.output; + const output = (opts.capture ? "(" : "") + token.open; + increment("parens"); + push({type, value: value2, output: state.output ? "" : ONE_CHAR}); + push({type: "paren", extglob: true, value: advance(), output}); + extglobs.push(token); + }; + const extglobClose = (token) => { + let output = token.close + (opts.capture ? ")" : ""); + let rest; + if (token.type === "negate") { + let extglobStar = star; + if (token.inner && token.inner.length > 1 && token.inner.includes("/")) { + extglobStar = globstar(opts); + } + if (extglobStar !== star || eos() || /^\)+$/.test(remaining())) { + output = token.close = `)$))${extglobStar}`; + } + if (token.inner.includes("*") && (rest = remaining()) && /^\.[^\\/.]+$/.test(rest)) { + output = token.close = `)${rest})${extglobStar})`; + } + if (token.prev.type === "bos") { + state.negatedExtglob = true; + } + } + push({type: "paren", extglob: true, value, output}); + decrement("parens"); + }; + if (opts.fastpaths !== false && !/(^[*!]|[/()[\]{}"])/.test(input)) { + let backslashes = false; + let output = input.replace(REGEX_SPECIAL_CHARS_BACKREF, (m, esc, chars, first, rest, index) => { + if (first === "\\") { + backslashes = true; + return m; + } + if (first === "?") { + if (esc) { + return esc + first + (rest ? QMARK.repeat(rest.length) : ""); + } + if (index === 0) { + return qmarkNoDot + (rest ? QMARK.repeat(rest.length) : ""); + } + return QMARK.repeat(chars.length); + } + if (first === ".") { + return DOT_LITERAL.repeat(chars.length); + } + if (first === "*") { + if (esc) { + return esc + first + (rest ? star : ""); + } + return star; + } + return esc ? m : `\\${m}`; + }); + if (backslashes === true) { + if (opts.unescape === true) { + output = output.replace(/\\/g, ""); + } else { + output = output.replace(/\\+/g, (m) => { + return m.length % 2 === 0 ? "\\\\" : m ? "\\" : ""; + }); + } + } + if (output === input && opts.contains === true) { + state.output = input; + return state; + } + state.output = utils.wrapOutput(output, state, options); + return state; + } + while (!eos()) { + value = advance(); + if (value === "\0") { + continue; + } + if (value === "\\") { + const next = peek(); + if (next === "/" && opts.bash !== true) { + continue; + } + if (next === "." || next === ";") { + continue; + } + if (!next) { + value += "\\"; + push({type: "text", value}); + continue; + } + const match = /^\\+/.exec(remaining()); + let slashes = 0; + if (match && match[0].length > 2) { + slashes = match[0].length; + state.index += slashes; + if (slashes % 2 !== 0) { + value += "\\"; + } + } + if (opts.unescape === true) { + value = advance(); + } else { + value += advance(); + } + if (state.brackets === 0) { + push({type: "text", value}); + continue; + } + } + if (state.brackets > 0 && (value !== "]" || prev.value === "[" || prev.value === "[^")) { + if (opts.posix !== false && value === ":") { + const inner = prev.value.slice(1); + if (inner.includes("[")) { + prev.posix = true; + if (inner.includes(":")) { + const idx = prev.value.lastIndexOf("["); + const pre = prev.value.slice(0, idx); + const rest2 = prev.value.slice(idx + 2); + const posix = POSIX_REGEX_SOURCE[rest2]; + if (posix) { + prev.value = pre + posix; + state.backtrack = true; + advance(); + if (!bos.output && tokens.indexOf(prev) === 1) { + bos.output = ONE_CHAR; + } + continue; + } + } + } + } + if (value === "[" && peek() !== ":" || value === "-" && peek() === "]") { + value = `\\${value}`; + } + if (value === "]" && (prev.value === "[" || prev.value === "[^")) { + value = `\\${value}`; + } + if (opts.posix === true && value === "!" && prev.value === "[") { + value = "^"; + } + prev.value += value; + append({value}); + continue; + } + if (state.quotes === 1 && value !== '"') { + value = utils.escapeRegex(value); + prev.value += value; + append({value}); + continue; + } + if (value === '"') { + state.quotes = state.quotes === 1 ? 0 : 1; + if (opts.keepQuotes === true) { + push({type: "text", value}); + } + continue; + } + if (value === "(") { + increment("parens"); + push({type: "paren", value}); + continue; + } + if (value === ")") { + if (state.parens === 0 && opts.strictBrackets === true) { + throw new SyntaxError(syntaxError("opening", "(")); + } + const extglob = extglobs[extglobs.length - 1]; + if (extglob && state.parens === extglob.parens + 1) { + extglobClose(extglobs.pop()); + continue; + } + push({type: "paren", value, output: state.parens ? ")" : "\\)"}); + decrement("parens"); + continue; + } + if (value === "[") { + if (opts.nobracket === true || !remaining().includes("]")) { + if (opts.nobracket !== true && opts.strictBrackets === true) { + throw new SyntaxError(syntaxError("closing", "]")); + } + value = `\\${value}`; + } else { + increment("brackets"); + } + push({type: "bracket", value}); + continue; + } + if (value === "]") { + if (opts.nobracket === true || prev && prev.type === "bracket" && prev.value.length === 1) { + push({type: "text", value, output: `\\${value}`}); + continue; + } + if (state.brackets === 0) { + if (opts.strictBrackets === true) { + throw new SyntaxError(syntaxError("opening", "[")); + } + push({type: "text", value, output: `\\${value}`}); + continue; + } + decrement("brackets"); + const prevValue = prev.value.slice(1); + if (prev.posix !== true && prevValue[0] === "^" && !prevValue.includes("/")) { + value = `/${value}`; + } + prev.value += value; + append({value}); + if (opts.literalBrackets === false || utils.hasRegexChars(prevValue)) { + continue; + } + const escaped = utils.escapeRegex(prev.value); + state.output = state.output.slice(0, -prev.value.length); + if (opts.literalBrackets === true) { + state.output += escaped; + prev.value = escaped; + continue; + } + prev.value = `(${capture}${escaped}|${prev.value})`; + state.output += prev.value; + continue; + } + if (value === "{" && opts.nobrace !== true) { + increment("braces"); + const open = { + type: "brace", + value, + output: "(", + outputIndex: state.output.length, + tokensIndex: state.tokens.length + }; + braces.push(open); + push(open); + continue; + } + if (value === "}") { + const brace = braces[braces.length - 1]; + if (opts.nobrace === true || !brace) { + push({type: "text", value, output: value}); + continue; + } + let output = ")"; + if (brace.dots === true) { + const arr = tokens.slice(); + const range = []; + for (let i = arr.length - 1; i >= 0; i--) { + tokens.pop(); + if (arr[i].type === "brace") { + break; + } + if (arr[i].type !== "dots") { + range.unshift(arr[i].value); + } + } + output = expandRange(range, opts); + state.backtrack = true; + } + if (brace.comma !== true && brace.dots !== true) { + const out = state.output.slice(0, brace.outputIndex); + const toks = state.tokens.slice(brace.tokensIndex); + brace.value = brace.output = "\\{"; + value = output = "\\}"; + state.output = out; + for (const t of toks) { + state.output += t.output || t.value; + } + } + push({type: "brace", value, output}); + decrement("braces"); + braces.pop(); + continue; + } + if (value === "|") { + if (extglobs.length > 0) { + extglobs[extglobs.length - 1].conditions++; + } + push({type: "text", value}); + continue; + } + if (value === ",") { + let output = value; + const brace = braces[braces.length - 1]; + if (brace && stack[stack.length - 1] === "braces") { + brace.comma = true; + output = "|"; + } + push({type: "comma", value, output}); + continue; + } + if (value === "/") { + if (prev.type === "dot" && state.index === state.start + 1) { + state.start = state.index + 1; + state.consumed = ""; + state.output = ""; + tokens.pop(); + prev = bos; + continue; + } + push({type: "slash", value, output: SLASH_LITERAL}); + continue; + } + if (value === ".") { + if (state.braces > 0 && prev.type === "dot") { + if (prev.value === ".") + prev.output = DOT_LITERAL; + const brace = braces[braces.length - 1]; + prev.type = "dots"; + prev.output += value; + prev.value += value; + brace.dots = true; + continue; + } + if (state.braces + state.parens === 0 && prev.type !== "bos" && prev.type !== "slash") { + push({type: "text", value, output: DOT_LITERAL}); + continue; + } + push({type: "dot", value, output: DOT_LITERAL}); + continue; + } + if (value === "?") { + const isGroup = prev && prev.value === "("; + if (!isGroup && opts.noextglob !== true && peek() === "(" && peek(2) !== "?") { + extglobOpen("qmark", value); + continue; + } + if (prev && prev.type === "paren") { + const next = peek(); + let output = value; + if (next === "<" && !utils.supportsLookbehinds()) { + throw new Error("Node.js v10 or higher is required for regex lookbehinds"); + } + if (prev.value === "(" && !/[!=<:]/.test(next) || next === "<" && !/<([!=]|\w+>)/.test(remaining())) { + output = `\\${value}`; + } + push({type: "text", value, output}); + continue; + } + if (opts.dot !== true && (prev.type === "slash" || prev.type === "bos")) { + push({type: "qmark", value, output: QMARK_NO_DOT}); + continue; + } + push({type: "qmark", value, output: QMARK}); + continue; + } + if (value === "!") { + if (opts.noextglob !== true && peek() === "(") { + if (peek(2) !== "?" || !/[!=<:]/.test(peek(3))) { + extglobOpen("negate", value); + continue; + } + } + if (opts.nonegate !== true && state.index === 0) { + negate(); + continue; + } + } + if (value === "+") { + if (opts.noextglob !== true && peek() === "(" && peek(2) !== "?") { + extglobOpen("plus", value); + continue; + } + if (prev && prev.value === "(" || opts.regex === false) { + push({type: "plus", value, output: PLUS_LITERAL}); + continue; + } + if (prev && (prev.type === "bracket" || prev.type === "paren" || prev.type === "brace") || state.parens > 0) { + push({type: "plus", value}); + continue; + } + push({type: "plus", value: PLUS_LITERAL}); + continue; + } + if (value === "@") { + if (opts.noextglob !== true && peek() === "(" && peek(2) !== "?") { + push({type: "at", extglob: true, value, output: ""}); + continue; + } + push({type: "text", value}); + continue; + } + if (value !== "*") { + if (value === "$" || value === "^") { + value = `\\${value}`; + } + const match = REGEX_NON_SPECIAL_CHARS.exec(remaining()); + if (match) { + value += match[0]; + state.index += match[0].length; + } + push({type: "text", value}); + continue; + } + if (prev && (prev.type === "globstar" || prev.star === true)) { + prev.type = "star"; + prev.star = true; + prev.value += value; + prev.output = star; + state.backtrack = true; + state.globstar = true; + consume(value); + continue; + } + let rest = remaining(); + if (opts.noextglob !== true && /^\([^?]/.test(rest)) { + extglobOpen("star", value); + continue; + } + if (prev.type === "star") { + if (opts.noglobstar === true) { + consume(value); + continue; + } + const prior = prev.prev; + const before = prior.prev; + const isStart = prior.type === "slash" || prior.type === "bos"; + const afterStar = before && (before.type === "star" || before.type === "globstar"); + if (opts.bash === true && (!isStart || rest[0] && rest[0] !== "/")) { + push({type: "star", value, output: ""}); + continue; + } + const isBrace = state.braces > 0 && (prior.type === "comma" || prior.type === "brace"); + const isExtglob = extglobs.length && (prior.type === "pipe" || prior.type === "paren"); + if (!isStart && prior.type !== "paren" && !isBrace && !isExtglob) { + push({type: "star", value, output: ""}); + continue; + } + while (rest.slice(0, 3) === "/**") { + const after = input[state.index + 4]; + if (after && after !== "/") { + break; + } + rest = rest.slice(3); + consume("/**", 3); + } + if (prior.type === "bos" && eos()) { + prev.type = "globstar"; + prev.value += value; + prev.output = globstar(opts); + state.output = prev.output; + state.globstar = true; + consume(value); + continue; + } + if (prior.type === "slash" && prior.prev.type !== "bos" && !afterStar && eos()) { + state.output = state.output.slice(0, -(prior.output + prev.output).length); + prior.output = `(?:${prior.output}`; + prev.type = "globstar"; + prev.output = globstar(opts) + (opts.strictSlashes ? ")" : "|$)"); + prev.value += value; + state.globstar = true; + state.output += prior.output + prev.output; + consume(value); + continue; + } + if (prior.type === "slash" && prior.prev.type !== "bos" && rest[0] === "/") { + const end = rest[1] !== void 0 ? "|$" : ""; + state.output = state.output.slice(0, -(prior.output + prev.output).length); + prior.output = `(?:${prior.output}`; + prev.type = "globstar"; + prev.output = `${globstar(opts)}${SLASH_LITERAL}|${SLASH_LITERAL}${end})`; + prev.value += value; + state.output += prior.output + prev.output; + state.globstar = true; + consume(value + advance()); + push({type: "slash", value: "/", output: ""}); + continue; + } + if (prior.type === "bos" && rest[0] === "/") { + prev.type = "globstar"; + prev.value += value; + prev.output = `(?:^|${SLASH_LITERAL}|${globstar(opts)}${SLASH_LITERAL})`; + state.output = prev.output; + state.globstar = true; + consume(value + advance()); + push({type: "slash", value: "/", output: ""}); + continue; + } + state.output = state.output.slice(0, -prev.output.length); + prev.type = "globstar"; + prev.output = globstar(opts); + prev.value += value; + state.output += prev.output; + state.globstar = true; + consume(value); + continue; + } + const token = {type: "star", value, output: star}; + if (opts.bash === true) { + token.output = ".*?"; + if (prev.type === "bos" || prev.type === "slash") { + token.output = nodot + token.output; + } + push(token); + continue; + } + if (prev && (prev.type === "bracket" || prev.type === "paren") && opts.regex === true) { + token.output = value; + push(token); + continue; + } + if (state.index === state.start || prev.type === "slash" || prev.type === "dot") { + if (prev.type === "dot") { + state.output += NO_DOT_SLASH; + prev.output += NO_DOT_SLASH; + } else if (opts.dot === true) { + state.output += NO_DOTS_SLASH; + prev.output += NO_DOTS_SLASH; + } else { + state.output += nodot; + prev.output += nodot; + } + if (peek() !== "*") { + state.output += ONE_CHAR; + prev.output += ONE_CHAR; + } + } + push(token); + } + while (state.brackets > 0) { + if (opts.strictBrackets === true) + throw new SyntaxError(syntaxError("closing", "]")); + state.output = utils.escapeLast(state.output, "["); + decrement("brackets"); + } + while (state.parens > 0) { + if (opts.strictBrackets === true) + throw new SyntaxError(syntaxError("closing", ")")); + state.output = utils.escapeLast(state.output, "("); + decrement("parens"); + } + while (state.braces > 0) { + if (opts.strictBrackets === true) + throw new SyntaxError(syntaxError("closing", "}")); + state.output = utils.escapeLast(state.output, "{"); + decrement("braces"); + } + if (opts.strictSlashes !== true && (prev.type === "star" || prev.type === "bracket")) { + push({type: "maybe_slash", value: "", output: `${SLASH_LITERAL}?`}); + } + if (state.backtrack === true) { + state.output = ""; + for (const token of state.tokens) { + state.output += token.output != null ? token.output : token.value; + if (token.suffix) { + state.output += token.suffix; + } + } + } + return state; + }; + parse.fastpaths = (input, options) => { + const opts = {...options}; + const max = typeof opts.maxLength === "number" ? Math.min(MAX_LENGTH, opts.maxLength) : MAX_LENGTH; + const len = input.length; + if (len > max) { + throw new SyntaxError(`Input length: ${len}, exceeds maximum allowed length: ${max}`); + } + input = REPLACEMENTS[input] || input; + const win32 = utils.isWindows(options); + const { + DOT_LITERAL, + SLASH_LITERAL, + ONE_CHAR, + DOTS_SLASH, + NO_DOT, + NO_DOTS, + NO_DOTS_SLASH, + STAR, + START_ANCHOR + } = constants.globChars(win32); + const nodot = opts.dot ? NO_DOTS : NO_DOT; + const slashDot = opts.dot ? NO_DOTS_SLASH : NO_DOT; + const capture = opts.capture ? "" : "?:"; + const state = {negated: false, prefix: ""}; + let star = opts.bash === true ? ".*?" : STAR; + if (opts.capture) { + star = `(${star})`; + } + const globstar = (opts2) => { + if (opts2.noglobstar === true) + return star; + return `(${capture}(?:(?!${START_ANCHOR}${opts2.dot ? DOTS_SLASH : DOT_LITERAL}).)*?)`; + }; + const create = (str) => { + switch (str) { + case "*": + return `${nodot}${ONE_CHAR}${star}`; + case ".*": + return `${DOT_LITERAL}${ONE_CHAR}${star}`; + case "*.*": + return `${nodot}${star}${DOT_LITERAL}${ONE_CHAR}${star}`; + case "*/*": + return `${nodot}${star}${SLASH_LITERAL}${ONE_CHAR}${slashDot}${star}`; + case "**": + return nodot + globstar(opts); + case "**/*": + return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${slashDot}${ONE_CHAR}${star}`; + case "**/*.*": + return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${slashDot}${star}${DOT_LITERAL}${ONE_CHAR}${star}`; + case "**/.*": + return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${DOT_LITERAL}${ONE_CHAR}${star}`; + default: { + const match = /^(.*?)\.(\w+)$/.exec(str); + if (!match) + return; + const source2 = create(match[1]); + if (!source2) + return; + return source2 + DOT_LITERAL + match[2]; + } + } + }; + const output = utils.removePrefix(input, state); + let source = create(output); + if (source && opts.strictSlashes !== true) { + source += `${SLASH_LITERAL}?`; + } + return source; + }; + module2.exports = parse; +}); + +// ../../node_modules/picomatch/lib/picomatch.js +var require_picomatch = __commonJS((exports2, module2) => { + "use strict"; + var path = require("path"); + var scan = require_scan(); + var parse = require_parse(); + var utils = require_utils(); + var constants = require_constants(); + var isObject = (val) => val && typeof val === "object" && !Array.isArray(val); + var picomatch = (glob, options, returnState = false) => { + if (Array.isArray(glob)) { + const fns = glob.map((input) => picomatch(input, options, returnState)); + const arrayMatcher = (str) => { + for (const isMatch of fns) { + const state2 = isMatch(str); + if (state2) + return state2; + } + return false; + }; + return arrayMatcher; + } + const isState = isObject(glob) && glob.tokens && glob.input; + if (glob === "" || typeof glob !== "string" && !isState) { + throw new TypeError("Expected pattern to be a non-empty string"); + } + const opts = options || {}; + const posix = utils.isWindows(options); + const regex = isState ? picomatch.compileRe(glob, options) : picomatch.makeRe(glob, options, false, true); + const state = regex.state; + delete regex.state; + let isIgnored = () => false; + if (opts.ignore) { + const ignoreOpts = {...options, ignore: null, onMatch: null, onResult: null}; + isIgnored = picomatch(opts.ignore, ignoreOpts, returnState); + } + const matcher = (input, returnObject = false) => { + const {isMatch, match, output} = picomatch.test(input, regex, options, {glob, posix}); + const result = {glob, state, regex, posix, input, output, match, isMatch}; + if (typeof opts.onResult === "function") { + opts.onResult(result); + } + if (isMatch === false) { + result.isMatch = false; + return returnObject ? result : false; + } + if (isIgnored(input)) { + if (typeof opts.onIgnore === "function") { + opts.onIgnore(result); + } + result.isMatch = false; + return returnObject ? result : false; + } + if (typeof opts.onMatch === "function") { + opts.onMatch(result); + } + return returnObject ? result : true; + }; + if (returnState) { + matcher.state = state; + } + return matcher; + }; + picomatch.test = (input, regex, options, {glob, posix} = {}) => { + if (typeof input !== "string") { + throw new TypeError("Expected input to be a string"); + } + if (input === "") { + return {isMatch: false, output: ""}; + } + const opts = options || {}; + const format = opts.format || (posix ? utils.toPosixSlashes : null); + let match = input === glob; + let output = match && format ? format(input) : input; + if (match === false) { + output = format ? format(input) : input; + match = output === glob; + } + if (match === false || opts.capture === true) { + if (opts.matchBase === true || opts.basename === true) { + match = picomatch.matchBase(input, regex, options, posix); + } else { + match = regex.exec(output); + } + } + return {isMatch: Boolean(match), match, output}; + }; + picomatch.matchBase = (input, glob, options, posix = utils.isWindows(options)) => { + const regex = glob instanceof RegExp ? glob : picomatch.makeRe(glob, options); + return regex.test(path.basename(input)); + }; + picomatch.isMatch = (str, patterns, options) => picomatch(patterns, options)(str); + picomatch.parse = (pattern, options) => { + if (Array.isArray(pattern)) + return pattern.map((p) => picomatch.parse(p, options)); + return parse(pattern, {...options, fastpaths: false}); + }; + picomatch.scan = (input, options) => scan(input, options); + picomatch.compileRe = (state, options, returnOutput = false, returnState = false) => { + if (returnOutput === true) { + return state.output; + } + const opts = options || {}; + const prepend = opts.contains ? "" : "^"; + const append = opts.contains ? "" : "$"; + let source = `${prepend}(?:${state.output})${append}`; + if (state && state.negated === true) { + source = `^(?!${source}).*$`; + } + const regex = picomatch.toRegex(source, options); + if (returnState === true) { + regex.state = state; + } + return regex; + }; + picomatch.makeRe = (input, options = {}, returnOutput = false, returnState = false) => { + if (!input || typeof input !== "string") { + throw new TypeError("Expected a non-empty string"); + } + let parsed = {negated: false, fastpaths: true}; + if (options.fastpaths !== false && (input[0] === "." || input[0] === "*")) { + parsed.output = parse.fastpaths(input, options); + } + if (!parsed.output) { + parsed = parse(input, options); + } + return picomatch.compileRe(parsed, options, returnOutput, returnState); + }; + picomatch.toRegex = (source, options) => { + try { + const opts = options || {}; + return new RegExp(source, opts.flags || (opts.nocase ? "i" : "")); + } catch (err) { + if (options && options.debug === true) + throw err; + return /$^/; + } + }; + picomatch.constants = constants; + module2.exports = picomatch; +}); + +// ../../node_modules/picomatch/index.js +var require_picomatch2 = __commonJS((exports2, module2) => { + "use strict"; + module2.exports = require_picomatch(); +}); + +// ../../node_modules/readdirp/index.js +var require_readdirp = __commonJS((exports2, module2) => { + "use strict"; + var fs = require("fs"); + var {Readable} = require("stream"); + var sysPath = require("path"); + var {promisify} = require("util"); + var picomatch = require_picomatch2(); + var readdir = promisify(fs.readdir); + var stat = promisify(fs.stat); + var lstat = promisify(fs.lstat); + var realpath = promisify(fs.realpath); + var BANG = "!"; + var RECURSIVE_ERROR_CODE = "READDIRP_RECURSIVE_ERROR"; + var NORMAL_FLOW_ERRORS = new Set(["ENOENT", "EPERM", "EACCES", "ELOOP", RECURSIVE_ERROR_CODE]); + var FILE_TYPE = "files"; + var DIR_TYPE = "directories"; + var FILE_DIR_TYPE = "files_directories"; + var EVERYTHING_TYPE = "all"; + var ALL_TYPES = [FILE_TYPE, DIR_TYPE, FILE_DIR_TYPE, EVERYTHING_TYPE]; + var isNormalFlowError = (error) => NORMAL_FLOW_ERRORS.has(error.code); + var [maj, min] = process.versions.node.split(".").slice(0, 2).map((n) => Number.parseInt(n, 10)); + var wantBigintFsStats = process.platform === "win32" && (maj > 10 || maj === 10 && min >= 5); + var normalizeFilter = (filter) => { + if (filter === void 0) + return; + if (typeof filter === "function") + return filter; + if (typeof filter === "string") { + const glob = picomatch(filter.trim()); + return (entry) => glob(entry.basename); + } + if (Array.isArray(filter)) { + const positive = []; + const negative = []; + for (const item of filter) { + const trimmed = item.trim(); + if (trimmed.charAt(0) === BANG) { + negative.push(picomatch(trimmed.slice(1))); + } else { + positive.push(picomatch(trimmed)); + } + } + if (negative.length > 0) { + if (positive.length > 0) { + return (entry) => positive.some((f) => f(entry.basename)) && !negative.some((f) => f(entry.basename)); + } + return (entry) => !negative.some((f) => f(entry.basename)); + } + return (entry) => positive.some((f) => f(entry.basename)); + } + }; + var ReaddirpStream = class extends Readable { + static get defaultOptions() { + return { + root: ".", + fileFilter: (path) => true, + directoryFilter: (path) => true, + type: FILE_TYPE, + lstat: false, + depth: 2147483648, + alwaysStat: false + }; + } + constructor(options = {}) { + super({ + objectMode: true, + autoDestroy: true, + highWaterMark: options.highWaterMark || 4096 + }); + const opts = {...ReaddirpStream.defaultOptions, ...options}; + const {root, type} = opts; + this._fileFilter = normalizeFilter(opts.fileFilter); + this._directoryFilter = normalizeFilter(opts.directoryFilter); + const statMethod = opts.lstat ? lstat : stat; + if (wantBigintFsStats) { + this._stat = (path) => statMethod(path, {bigint: true}); + } else { + this._stat = statMethod; + } + this._maxDepth = opts.depth; + this._wantsDir = [DIR_TYPE, FILE_DIR_TYPE, EVERYTHING_TYPE].includes(type); + this._wantsFile = [FILE_TYPE, FILE_DIR_TYPE, EVERYTHING_TYPE].includes(type); + this._wantsEverything = type === EVERYTHING_TYPE; + this._root = sysPath.resolve(root); + this._isDirent = "Dirent" in fs && !opts.alwaysStat; + this._statsProp = this._isDirent ? "dirent" : "stats"; + this._rdOptions = {encoding: "utf8", withFileTypes: this._isDirent}; + this.parents = [this._exploreDir(root, 1)]; + this.reading = false; + this.parent = void 0; + } + async _read(batch) { + if (this.reading) + return; + this.reading = true; + try { + while (!this.destroyed && batch > 0) { + const {path, depth, files = []} = this.parent || {}; + if (files.length > 0) { + const slice = files.splice(0, batch).map((dirent) => this._formatEntry(dirent, path)); + for (const entry of await Promise.all(slice)) { + if (this.destroyed) + return; + const entryType = await this._getEntryType(entry); + if (entryType === "directory" && this._directoryFilter(entry)) { + if (depth <= this._maxDepth) { + this.parents.push(this._exploreDir(entry.fullPath, depth + 1)); + } + if (this._wantsDir) { + this.push(entry); + batch--; + } + } else if ((entryType === "file" || this._includeAsFile(entry)) && this._fileFilter(entry)) { + if (this._wantsFile) { + this.push(entry); + batch--; + } + } + } + } else { + const parent = this.parents.pop(); + if (!parent) { + this.push(null); + break; + } + this.parent = await parent; + if (this.destroyed) + return; + } + } + } catch (error) { + this.destroy(error); + } finally { + this.reading = false; + } + } + async _exploreDir(path, depth) { + let files; + try { + files = await readdir(path, this._rdOptions); + } catch (error) { + this._onError(error); + } + return {files, depth, path}; + } + async _formatEntry(dirent, path) { + let entry; + try { + const basename = this._isDirent ? dirent.name : dirent; + const fullPath = sysPath.resolve(sysPath.join(path, basename)); + entry = {path: sysPath.relative(this._root, fullPath), fullPath, basename}; + entry[this._statsProp] = this._isDirent ? dirent : await this._stat(fullPath); + } catch (err) { + this._onError(err); + } + return entry; + } + _onError(err) { + if (isNormalFlowError(err) && !this.destroyed) { + this.emit("warn", err); + } else { + this.destroy(err); + } + } + async _getEntryType(entry) { + const stats = entry && entry[this._statsProp]; + if (!stats) { + return; + } + if (stats.isFile()) { + return "file"; + } + if (stats.isDirectory()) { + return "directory"; + } + if (stats && stats.isSymbolicLink()) { + const full = entry.fullPath; + try { + const entryRealPath = await realpath(full); + const entryRealPathStats = await lstat(entryRealPath); + if (entryRealPathStats.isFile()) { + return "file"; + } + if (entryRealPathStats.isDirectory()) { + const len = entryRealPath.length; + if (full.startsWith(entryRealPath) && full.substr(len, 1) === sysPath.sep) { + const recursiveError = new Error(`Circular symlink detected: "${full}" points to "${entryRealPath}"`); + recursiveError.code = RECURSIVE_ERROR_CODE; + return this._onError(recursiveError); + } + return "directory"; + } + } catch (error) { + this._onError(error); + } + } + } + _includeAsFile(entry) { + const stats = entry && entry[this._statsProp]; + return stats && this._wantsEverything && !stats.isDirectory(); + } + }; + var readdirp = (root, options = {}) => { + let type = options.entryType || options.type; + if (type === "both") + type = FILE_DIR_TYPE; + if (type) + options.type = type; + if (!root) { + throw new Error("readdirp: root argument is required. Usage: readdirp(root, options)"); + } else if (typeof root !== "string") { + throw new TypeError("readdirp: root argument must be a string. Usage: readdirp(root, options)"); + } else if (type && !ALL_TYPES.includes(type)) { + throw new Error(`readdirp: Invalid type passed. Use one of ${ALL_TYPES.join(", ")}`); + } + options.root = root; + return new ReaddirpStream(options); + }; + var readdirpPromise = (root, options = {}) => { + return new Promise((resolve, reject) => { + const files = []; + readdirp(root, options).on("data", (entry) => files.push(entry)).on("end", () => resolve(files)).on("error", (error) => reject(error)); + }); + }; + readdirp.promise = readdirpPromise; + readdirp.ReaddirpStream = ReaddirpStream; + readdirp.default = readdirp; + module2.exports = readdirp; +}); + +// ../../node_modules/normalize-path/index.js +var require_normalize_path = __commonJS((exports2, module2) => { + /*! + * normalize-path + * + * Copyright (c) 2014-2018, Jon Schlinkert. + * Released under the MIT License. + */ + module2.exports = function(path, stripTrailing) { + if (typeof path !== "string") { + throw new TypeError("expected path to be a string"); + } + if (path === "\\" || path === "/") + return "/"; + var len = path.length; + if (len <= 1) + return path; + var prefix = ""; + if (len > 4 && path[3] === "\\") { + var ch = path[2]; + if ((ch === "?" || ch === ".") && path.slice(0, 2) === "\\\\") { + path = path.slice(2); + prefix = "//"; + } + } + var segs = path.split(/[/\\]+/); + if (stripTrailing !== false && segs[segs.length - 1] === "") { + segs.pop(); + } + return prefix + segs.join("/"); + }; +}); + +// ../../node_modules/anymatch/index.js +var require_anymatch = __commonJS((exports2, module2) => { + "use strict"; + Object.defineProperty(exports2, "__esModule", {value: true}); + var picomatch = require_picomatch2(); + var normalizePath = require_normalize_path(); + var BANG = "!"; + var DEFAULT_OPTIONS = {returnIndex: false}; + var arrify = (item) => Array.isArray(item) ? item : [item]; + var createPattern = (matcher, options) => { + if (typeof matcher === "function") { + return matcher; + } + if (typeof matcher === "string") { + const glob = picomatch(matcher, options); + return (string) => matcher === string || glob(string); + } + if (matcher instanceof RegExp) { + return (string) => matcher.test(string); + } + return (string) => false; + }; + var matchPatterns = (patterns, negPatterns, args, returnIndex) => { + const isList = Array.isArray(args); + const _path = isList ? args[0] : args; + if (!isList && typeof _path !== "string") { + throw new TypeError("anymatch: second argument must be a string: got " + Object.prototype.toString.call(_path)); + } + const path = normalizePath(_path); + for (let index = 0; index < negPatterns.length; index++) { + const nglob = negPatterns[index]; + if (nglob(path)) { + return returnIndex ? -1 : false; + } + } + const applied = isList && [path].concat(args.slice(1)); + for (let index = 0; index < patterns.length; index++) { + const pattern = patterns[index]; + if (isList ? pattern(...applied) : pattern(path)) { + return returnIndex ? index : true; + } + } + return returnIndex ? -1 : false; + }; + var anymatch = (matchers, testString, options = DEFAULT_OPTIONS) => { + if (matchers == null) { + throw new TypeError("anymatch: specify first argument"); + } + const opts = typeof options === "boolean" ? {returnIndex: options} : options; + const returnIndex = opts.returnIndex || false; + const mtchers = arrify(matchers); + const negatedGlobs = mtchers.filter((item) => typeof item === "string" && item.charAt(0) === BANG).map((item) => item.slice(1)).map((item) => picomatch(item, opts)); + const patterns = mtchers.filter((item) => typeof item !== "string" || typeof item === "string" && item.charAt(0) !== BANG).map((matcher) => createPattern(matcher, opts)); + if (testString == null) { + return (testString2, ri = false) => { + const returnIndex2 = typeof ri === "boolean" ? ri : false; + return matchPatterns(patterns, negatedGlobs, testString2, returnIndex2); + }; + } + return matchPatterns(patterns, negatedGlobs, testString, returnIndex); + }; + anymatch.default = anymatch; + module2.exports = anymatch; +}); + +// ../../node_modules/is-extglob/index.js +var require_is_extglob = __commonJS((exports2, module2) => { + /*! + * is-extglob + * + * Copyright (c) 2014-2016, Jon Schlinkert. + * Licensed under the MIT License. + */ + module2.exports = function isExtglob(str) { + if (typeof str !== "string" || str === "") { + return false; + } + var match; + while (match = /(\\).|([@?!+*]\(.*\))/g.exec(str)) { + if (match[2]) + return true; + str = str.slice(match.index + match[0].length); + } + return false; + }; +}); + +// ../../node_modules/is-glob/index.js +var require_is_glob = __commonJS((exports2, module2) => { + /*! + * is-glob + * + * Copyright (c) 2014-2017, Jon Schlinkert. + * Released under the MIT License. + */ + var isExtglob = require_is_extglob(); + var chars = {"{": "}", "(": ")", "[": "]"}; + var strictRegex = /\\(.)|(^!|\*|[\].+)]\?|\[[^\\\]]+\]|\{[^\\}]+\}|\(\?[:!=][^\\)]+\)|\([^|]+\|[^\\)]+\))/; + var relaxedRegex = /\\(.)|(^!|[*?{}()[\]]|\(\?)/; + module2.exports = function isGlob(str, options) { + if (typeof str !== "string" || str === "") { + return false; + } + if (isExtglob(str)) { + return true; + } + var regex = strictRegex; + var match; + if (options && options.strict === false) { + regex = relaxedRegex; + } + while (match = regex.exec(str)) { + if (match[2]) + return true; + var idx = match.index + match[0].length; + var open = match[1]; + var close = open ? chars[open] : null; + if (open && close) { + var n = str.indexOf(close, idx); + if (n !== -1) { + idx = n + 1; + } + } + str = str.slice(idx); + } + return false; + }; +}); + +// ../../node_modules/glob-parent/index.js +var require_glob_parent = __commonJS((exports2, module2) => { + "use strict"; + var isGlob = require_is_glob(); + var pathPosixDirname = require("path").posix.dirname; + var isWin32 = require("os").platform() === "win32"; + var slash = "/"; + var backslash = /\\/g; + var enclosure = /[\{\[].*[\}\]]$/; + var globby = /(^|[^\\])([\{\[]|\([^\)]+$)/; + var escaped = /\\([\!\*\?\|\[\]\(\)\{\}])/g; + module2.exports = function globParent(str, opts) { + var options = Object.assign({flipBackslashes: true}, opts); + if (options.flipBackslashes && isWin32 && str.indexOf(slash) < 0) { + str = str.replace(backslash, slash); + } + if (enclosure.test(str)) { + str += slash; + } + str += "a"; + do { + str = pathPosixDirname(str); + } while (isGlob(str) || globby.test(str)); + return str.replace(escaped, "$1"); + }; +}); + +// ../../node_modules/chokidar/node_modules/braces/lib/utils.js +var require_utils2 = __commonJS((exports2) => { + "use strict"; + exports2.isInteger = (num) => { + if (typeof num === "number") { + return Number.isInteger(num); + } + if (typeof num === "string" && num.trim() !== "") { + return Number.isInteger(Number(num)); + } + return false; + }; + exports2.find = (node, type) => node.nodes.find((node2) => node2.type === type); + exports2.exceedsLimit = (min, max, step = 1, limit) => { + if (limit === false) + return false; + if (!exports2.isInteger(min) || !exports2.isInteger(max)) + return false; + return (Number(max) - Number(min)) / Number(step) >= limit; + }; + exports2.escapeNode = (block, n = 0, type) => { + let node = block.nodes[n]; + if (!node) + return; + if (type && node.type === type || node.type === "open" || node.type === "close") { + if (node.escaped !== true) { + node.value = "\\" + node.value; + node.escaped = true; + } + } + }; + exports2.encloseBrace = (node) => { + if (node.type !== "brace") + return false; + if (node.commas >> 0 + node.ranges >> 0 === 0) { + node.invalid = true; + return true; + } + return false; + }; + exports2.isInvalidBrace = (block) => { + if (block.type !== "brace") + return false; + if (block.invalid === true || block.dollar) + return true; + if (block.commas >> 0 + block.ranges >> 0 === 0) { + block.invalid = true; + return true; + } + if (block.open !== true || block.close !== true) { + block.invalid = true; + return true; + } + return false; + }; + exports2.isOpenOrClose = (node) => { + if (node.type === "open" || node.type === "close") { + return true; + } + return node.open === true || node.close === true; + }; + exports2.reduce = (nodes2) => nodes2.reduce((acc, node) => { + if (node.type === "text") + acc.push(node.value); + if (node.type === "range") + node.type = "text"; + return acc; + }, []); + exports2.flatten = (...args) => { + const result = []; + const flat = (arr) => { + for (let i = 0; i < arr.length; i++) { + let ele = arr[i]; + Array.isArray(ele) ? flat(ele, result) : ele !== void 0 && result.push(ele); + } + return result; + }; + flat(args); + return result; + }; +}); + +// ../../node_modules/chokidar/node_modules/braces/lib/stringify.js +var require_stringify = __commonJS((exports2, module2) => { + "use strict"; + var utils = require_utils2(); + module2.exports = (ast, options = {}) => { + let stringify = (node, parent = {}) => { + let invalidBlock = options.escapeInvalid && utils.isInvalidBrace(parent); + let invalidNode = node.invalid === true && options.escapeInvalid === true; + let output = ""; + if (node.value) { + if ((invalidBlock || invalidNode) && utils.isOpenOrClose(node)) { + return "\\" + node.value; + } + return node.value; + } + if (node.value) { + return node.value; + } + if (node.nodes) { + for (let child of node.nodes) { + output += stringify(child); + } + } + return output; + }; + return stringify(ast); + }; +}); + +// ../../node_modules/chokidar/node_modules/is-number/index.js +var require_is_number = __commonJS((exports2, module2) => { + /*! + * is-number + * + * Copyright (c) 2014-present, Jon Schlinkert. + * Released under the MIT License. + */ + "use strict"; + module2.exports = function(num) { + if (typeof num === "number") { + return num - num === 0; + } + if (typeof num === "string" && num.trim() !== "") { + return Number.isFinite ? Number.isFinite(+num) : isFinite(+num); + } + return false; + }; +}); + +// ../../node_modules/chokidar/node_modules/to-regex-range/index.js +var require_to_regex_range = __commonJS((exports2, module2) => { + /*! + * to-regex-range + * + * Copyright (c) 2015-present, Jon Schlinkert. + * Released under the MIT License. + */ + "use strict"; + var isNumber = require_is_number(); + var toRegexRange = (min, max, options) => { + if (isNumber(min) === false) { + throw new TypeError("toRegexRange: expected the first argument to be a number"); + } + if (max === void 0 || min === max) { + return String(min); + } + if (isNumber(max) === false) { + throw new TypeError("toRegexRange: expected the second argument to be a number."); + } + let opts = {relaxZeros: true, ...options}; + if (typeof opts.strictZeros === "boolean") { + opts.relaxZeros = opts.strictZeros === false; + } + let relax = String(opts.relaxZeros); + let shorthand = String(opts.shorthand); + let capture = String(opts.capture); + let wrap = String(opts.wrap); + let cacheKey = min + ":" + max + "=" + relax + shorthand + capture + wrap; + if (toRegexRange.cache.hasOwnProperty(cacheKey)) { + return toRegexRange.cache[cacheKey].result; + } + let a = Math.min(min, max); + let b = Math.max(min, max); + if (Math.abs(a - b) === 1) { + let result = min + "|" + max; + if (opts.capture) { + return `(${result})`; + } + if (opts.wrap === false) { + return result; + } + return `(?:${result})`; + } + let isPadded = hasPadding(min) || hasPadding(max); + let state = {min, max, a, b}; + let positives = []; + let negatives = []; + if (isPadded) { + state.isPadded = isPadded; + state.maxLen = String(state.max).length; + } + if (a < 0) { + let newMin = b < 0 ? Math.abs(b) : 1; + negatives = splitToPatterns(newMin, Math.abs(a), state, opts); + a = state.a = 0; + } + if (b >= 0) { + positives = splitToPatterns(a, b, state, opts); + } + state.negatives = negatives; + state.positives = positives; + state.result = collatePatterns(negatives, positives, opts); + if (opts.capture === true) { + state.result = `(${state.result})`; + } else if (opts.wrap !== false && positives.length + negatives.length > 1) { + state.result = `(?:${state.result})`; + } + toRegexRange.cache[cacheKey] = state; + return state.result; + }; + function collatePatterns(neg, pos, options) { + let onlyNegative = filterPatterns(neg, pos, "-", false, options) || []; + let onlyPositive = filterPatterns(pos, neg, "", false, options) || []; + let intersected = filterPatterns(neg, pos, "-?", true, options) || []; + let subpatterns = onlyNegative.concat(intersected).concat(onlyPositive); + return subpatterns.join("|"); + } + function splitToRanges(min, max) { + let nines = 1; + let zeros = 1; + let stop = countNines(min, nines); + let stops = new Set([max]); + while (min <= stop && stop <= max) { + stops.add(stop); + nines += 1; + stop = countNines(min, nines); + } + stop = countZeros(max + 1, zeros) - 1; + while (min < stop && stop <= max) { + stops.add(stop); + zeros += 1; + stop = countZeros(max + 1, zeros) - 1; + } + stops = [...stops]; + stops.sort(compare); + return stops; + } + function rangeToPattern(start, stop, options) { + if (start === stop) { + return {pattern: start, count: [], digits: 0}; + } + let zipped = zip(start, stop); + let digits = zipped.length; + let pattern = ""; + let count = 0; + for (let i = 0; i < digits; i++) { + let [startDigit, stopDigit] = zipped[i]; + if (startDigit === stopDigit) { + pattern += startDigit; + } else if (startDigit !== "0" || stopDigit !== "9") { + pattern += toCharacterClass(startDigit, stopDigit, options); + } else { + count++; + } + } + if (count) { + pattern += options.shorthand === true ? "\\d" : "[0-9]"; + } + return {pattern, count: [count], digits}; + } + function splitToPatterns(min, max, tok, options) { + let ranges = splitToRanges(min, max); + let tokens = []; + let start = min; + let prev; + for (let i = 0; i < ranges.length; i++) { + let max2 = ranges[i]; + let obj = rangeToPattern(String(start), String(max2), options); + let zeros = ""; + if (!tok.isPadded && prev && prev.pattern === obj.pattern) { + if (prev.count.length > 1) { + prev.count.pop(); + } + prev.count.push(obj.count[0]); + prev.string = prev.pattern + toQuantifier(prev.count); + start = max2 + 1; + continue; + } + if (tok.isPadded) { + zeros = padZeros(max2, tok, options); + } + obj.string = zeros + obj.pattern + toQuantifier(obj.count); + tokens.push(obj); + start = max2 + 1; + prev = obj; + } + return tokens; + } + function filterPatterns(arr, comparison, prefix, intersection, options) { + let result = []; + for (let ele of arr) { + let {string} = ele; + if (!intersection && !contains(comparison, "string", string)) { + result.push(prefix + string); + } + if (intersection && contains(comparison, "string", string)) { + result.push(prefix + string); + } + } + return result; + } + function zip(a, b) { + let arr = []; + for (let i = 0; i < a.length; i++) + arr.push([a[i], b[i]]); + return arr; + } + function compare(a, b) { + return a > b ? 1 : b > a ? -1 : 0; + } + function contains(arr, key, val) { + return arr.some((ele) => ele[key] === val); + } + function countNines(min, len) { + return Number(String(min).slice(0, -len) + "9".repeat(len)); + } + function countZeros(integer, zeros) { + return integer - integer % Math.pow(10, zeros); + } + function toQuantifier(digits) { + let [start = 0, stop = ""] = digits; + if (stop || start > 1) { + return `{${start + (stop ? "," + stop : "")}}`; + } + return ""; + } + function toCharacterClass(a, b, options) { + return `[${a}${b - a === 1 ? "" : "-"}${b}]`; + } + function hasPadding(str) { + return /^-?(0+)\d/.test(str); + } + function padZeros(value, tok, options) { + if (!tok.isPadded) { + return value; + } + let diff = Math.abs(tok.maxLen - String(value).length); + let relax = options.relaxZeros !== false; + switch (diff) { + case 0: + return ""; + case 1: + return relax ? "0?" : "0"; + case 2: + return relax ? "0{0,2}" : "00"; + default: { + return relax ? `0{0,${diff}}` : `0{${diff}}`; + } + } + } + toRegexRange.cache = {}; + toRegexRange.clearCache = () => toRegexRange.cache = {}; + module2.exports = toRegexRange; +}); + +// ../../node_modules/chokidar/node_modules/fill-range/index.js +var require_fill_range = __commonJS((exports2, module2) => { + /*! + * fill-range + * + * Copyright (c) 2014-present, Jon Schlinkert. + * Licensed under the MIT License. + */ + "use strict"; + var util = require("util"); + var toRegexRange = require_to_regex_range(); + var isObject = (val) => val !== null && typeof val === "object" && !Array.isArray(val); + var transform = (toNumber) => { + return (value) => toNumber === true ? Number(value) : String(value); + }; + var isValidValue = (value) => { + return typeof value === "number" || typeof value === "string" && value !== ""; + }; + var isNumber = (num) => Number.isInteger(+num); + var zeros = (input) => { + let value = `${input}`; + let index = -1; + if (value[0] === "-") + value = value.slice(1); + if (value === "0") + return false; + while (value[++index] === "0") + ; + return index > 0; + }; + var stringify = (start, end, options) => { + if (typeof start === "string" || typeof end === "string") { + return true; + } + return options.stringify === true; + }; + var pad = (input, maxLength, toNumber) => { + if (maxLength > 0) { + let dash = input[0] === "-" ? "-" : ""; + if (dash) + input = input.slice(1); + input = dash + input.padStart(dash ? maxLength - 1 : maxLength, "0"); + } + if (toNumber === false) { + return String(input); + } + return input; + }; + var toMaxLen = (input, maxLength) => { + let negative = input[0] === "-" ? "-" : ""; + if (negative) { + input = input.slice(1); + maxLength--; + } + while (input.length < maxLength) + input = "0" + input; + return negative ? "-" + input : input; + }; + var toSequence = (parts, options) => { + parts.negatives.sort((a, b) => a < b ? -1 : a > b ? 1 : 0); + parts.positives.sort((a, b) => a < b ? -1 : a > b ? 1 : 0); + let prefix = options.capture ? "" : "?:"; + let positives = ""; + let negatives = ""; + let result; + if (parts.positives.length) { + positives = parts.positives.join("|"); + } + if (parts.negatives.length) { + negatives = `-(${prefix}${parts.negatives.join("|")})`; + } + if (positives && negatives) { + result = `${positives}|${negatives}`; + } else { + result = positives || negatives; + } + if (options.wrap) { + return `(${prefix}${result})`; + } + return result; + }; + var toRange = (a, b, isNumbers, options) => { + if (isNumbers) { + return toRegexRange(a, b, {wrap: false, ...options}); + } + let start = String.fromCharCode(a); + if (a === b) + return start; + let stop = String.fromCharCode(b); + return `[${start}-${stop}]`; + }; + var toRegex = (start, end, options) => { + if (Array.isArray(start)) { + let wrap = options.wrap === true; + let prefix = options.capture ? "" : "?:"; + return wrap ? `(${prefix}${start.join("|")})` : start.join("|"); + } + return toRegexRange(start, end, options); + }; + var rangeError = (...args) => { + return new RangeError("Invalid range arguments: " + util.inspect(...args)); + }; + var invalidRange = (start, end, options) => { + if (options.strictRanges === true) + throw rangeError([start, end]); + return []; + }; + var invalidStep = (step, options) => { + if (options.strictRanges === true) { + throw new TypeError(`Expected step "${step}" to be a number`); + } + return []; + }; + var fillNumbers = (start, end, step = 1, options = {}) => { + let a = Number(start); + let b = Number(end); + if (!Number.isInteger(a) || !Number.isInteger(b)) { + if (options.strictRanges === true) + throw rangeError([start, end]); + return []; + } + if (a === 0) + a = 0; + if (b === 0) + b = 0; + let descending = a > b; + let startString = String(start); + let endString = String(end); + let stepString = String(step); + step = Math.max(Math.abs(step), 1); + let padded = zeros(startString) || zeros(endString) || zeros(stepString); + let maxLen = padded ? Math.max(startString.length, endString.length, stepString.length) : 0; + let toNumber = padded === false && stringify(start, end, options) === false; + let format = options.transform || transform(toNumber); + if (options.toRegex && step === 1) { + return toRange(toMaxLen(start, maxLen), toMaxLen(end, maxLen), true, options); + } + let parts = {negatives: [], positives: []}; + let push = (num) => parts[num < 0 ? "negatives" : "positives"].push(Math.abs(num)); + let range = []; + let index = 0; + while (descending ? a >= b : a <= b) { + if (options.toRegex === true && step > 1) { + push(a); + } else { + range.push(pad(format(a, index), maxLen, toNumber)); + } + a = descending ? a - step : a + step; + index++; + } + if (options.toRegex === true) { + return step > 1 ? toSequence(parts, options) : toRegex(range, null, {wrap: false, ...options}); + } + return range; + }; + var fillLetters = (start, end, step = 1, options = {}) => { + if (!isNumber(start) && start.length > 1 || !isNumber(end) && end.length > 1) { + return invalidRange(start, end, options); + } + let format = options.transform || ((val) => String.fromCharCode(val)); + let a = `${start}`.charCodeAt(0); + let b = `${end}`.charCodeAt(0); + let descending = a > b; + let min = Math.min(a, b); + let max = Math.max(a, b); + if (options.toRegex && step === 1) { + return toRange(min, max, false, options); + } + let range = []; + let index = 0; + while (descending ? a >= b : a <= b) { + range.push(format(a, index)); + a = descending ? a - step : a + step; + index++; + } + if (options.toRegex === true) { + return toRegex(range, null, {wrap: false, options}); + } + return range; + }; + var fill = (start, end, step, options = {}) => { + if (end == null && isValidValue(start)) { + return [start]; + } + if (!isValidValue(start) || !isValidValue(end)) { + return invalidRange(start, end, options); + } + if (typeof step === "function") { + return fill(start, end, 1, {transform: step}); + } + if (isObject(step)) { + return fill(start, end, 0, step); + } + let opts = {...options}; + if (opts.capture === true) + opts.wrap = true; + step = step || opts.step || 1; + if (!isNumber(step)) { + if (step != null && !isObject(step)) + return invalidStep(step, opts); + return fill(start, end, 1, step); + } + if (isNumber(start) && isNumber(end)) { + return fillNumbers(start, end, step, opts); + } + return fillLetters(start, end, Math.max(Math.abs(step), 1), opts); + }; + module2.exports = fill; +}); + +// ../../node_modules/chokidar/node_modules/braces/lib/compile.js +var require_compile = __commonJS((exports2, module2) => { + "use strict"; + var fill = require_fill_range(); + var utils = require_utils2(); + var compile2 = (ast, options = {}) => { + let walk = (node, parent = {}) => { + let invalidBlock = utils.isInvalidBrace(parent); + let invalidNode = node.invalid === true && options.escapeInvalid === true; + let invalid = invalidBlock === true || invalidNode === true; + let prefix = options.escapeInvalid === true ? "\\" : ""; + let output = ""; + if (node.isOpen === true) { + return prefix + node.value; + } + if (node.isClose === true) { + return prefix + node.value; + } + if (node.type === "open") { + return invalid ? prefix + node.value : "("; + } + if (node.type === "close") { + return invalid ? prefix + node.value : ")"; + } + if (node.type === "comma") { + return node.prev.type === "comma" ? "" : invalid ? node.value : "|"; + } + if (node.value) { + return node.value; + } + if (node.nodes && node.ranges > 0) { + let args = utils.reduce(node.nodes); + let range = fill(...args, {...options, wrap: false, toRegex: true}); + if (range.length !== 0) { + return args.length > 1 && range.length > 1 ? `(${range})` : range; + } + } + if (node.nodes) { + for (let child of node.nodes) { + output += walk(child, node); + } + } + return output; + }; + return walk(ast); + }; + module2.exports = compile2; +}); + +// ../../node_modules/chokidar/node_modules/braces/lib/expand.js +var require_expand = __commonJS((exports2, module2) => { + "use strict"; + var fill = require_fill_range(); + var stringify = require_stringify(); + var utils = require_utils2(); + var append = (queue = "", stash = "", enclose = false) => { + let result = []; + queue = [].concat(queue); + stash = [].concat(stash); + if (!stash.length) + return queue; + if (!queue.length) { + return enclose ? utils.flatten(stash).map((ele) => `{${ele}}`) : stash; + } + for (let item of queue) { + if (Array.isArray(item)) { + for (let value of item) { + result.push(append(value, stash, enclose)); + } + } else { + for (let ele of stash) { + if (enclose === true && typeof ele === "string") + ele = `{${ele}}`; + result.push(Array.isArray(ele) ? append(item, ele, enclose) : item + ele); + } + } + } + return utils.flatten(result); + }; + var expand = (ast, options = {}) => { + let rangeLimit = options.rangeLimit === void 0 ? 1e3 : options.rangeLimit; + let walk = (node, parent = {}) => { + node.queue = []; + let p = parent; + let q = parent.queue; + while (p.type !== "brace" && p.type !== "root" && p.parent) { + p = p.parent; + q = p.queue; + } + if (node.invalid || node.dollar) { + q.push(append(q.pop(), stringify(node, options))); + return; + } + if (node.type === "brace" && node.invalid !== true && node.nodes.length === 2) { + q.push(append(q.pop(), ["{}"])); + return; + } + if (node.nodes && node.ranges > 0) { + let args = utils.reduce(node.nodes); + if (utils.exceedsLimit(...args, options.step, rangeLimit)) { + throw new RangeError("expanded array length exceeds range limit. Use options.rangeLimit to increase or disable the limit."); + } + let range = fill(...args, options); + if (range.length === 0) { + range = stringify(node, options); + } + q.push(append(q.pop(), range)); + node.nodes = []; + return; + } + let enclose = utils.encloseBrace(node); + let queue = node.queue; + let block = node; + while (block.type !== "brace" && block.type !== "root" && block.parent) { + block = block.parent; + queue = block.queue; + } + for (let i = 0; i < node.nodes.length; i++) { + let child = node.nodes[i]; + if (child.type === "comma" && node.type === "brace") { + if (i === 1) + queue.push(""); + queue.push(""); + continue; + } + if (child.type === "close") { + q.push(append(q.pop(), queue, enclose)); + continue; + } + if (child.value && child.type !== "open") { + queue.push(append(queue.pop(), child.value)); + continue; + } + if (child.nodes) { + walk(child, node); + } + } + return queue; + }; + return utils.flatten(walk(ast)); + }; + module2.exports = expand; +}); + +// ../../node_modules/chokidar/node_modules/braces/lib/constants.js +var require_constants2 = __commonJS((exports2, module2) => { + "use strict"; + module2.exports = { + MAX_LENGTH: 1024 * 64, + CHAR_0: "0", + CHAR_9: "9", + CHAR_UPPERCASE_A: "A", + CHAR_LOWERCASE_A: "a", + CHAR_UPPERCASE_Z: "Z", + CHAR_LOWERCASE_Z: "z", + CHAR_LEFT_PARENTHESES: "(", + CHAR_RIGHT_PARENTHESES: ")", + CHAR_ASTERISK: "*", + CHAR_AMPERSAND: "&", + CHAR_AT: "@", + CHAR_BACKSLASH: "\\", + CHAR_BACKTICK: "`", + CHAR_CARRIAGE_RETURN: "\r", + CHAR_CIRCUMFLEX_ACCENT: "^", + CHAR_COLON: ":", + CHAR_COMMA: ",", + CHAR_DOLLAR: "$", + CHAR_DOT: ".", + CHAR_DOUBLE_QUOTE: '"', + CHAR_EQUAL: "=", + CHAR_EXCLAMATION_MARK: "!", + CHAR_FORM_FEED: "\f", + CHAR_FORWARD_SLASH: "/", + CHAR_HASH: "#", + CHAR_HYPHEN_MINUS: "-", + CHAR_LEFT_ANGLE_BRACKET: "<", + CHAR_LEFT_CURLY_BRACE: "{", + CHAR_LEFT_SQUARE_BRACKET: "[", + CHAR_LINE_FEED: "\n", + CHAR_NO_BREAK_SPACE: "\xA0", + CHAR_PERCENT: "%", + CHAR_PLUS: "+", + CHAR_QUESTION_MARK: "?", + CHAR_RIGHT_ANGLE_BRACKET: ">", + CHAR_RIGHT_CURLY_BRACE: "}", + CHAR_RIGHT_SQUARE_BRACKET: "]", + CHAR_SEMICOLON: ";", + CHAR_SINGLE_QUOTE: "'", + CHAR_SPACE: " ", + CHAR_TAB: " ", + CHAR_UNDERSCORE: "_", + CHAR_VERTICAL_LINE: "|", + CHAR_ZERO_WIDTH_NOBREAK_SPACE: "\uFEFF" + }; +}); + +// ../../node_modules/chokidar/node_modules/braces/lib/parse.js +var require_parse2 = __commonJS((exports2, module2) => { + "use strict"; + var stringify = require_stringify(); + var { + MAX_LENGTH, + CHAR_BACKSLASH, + CHAR_BACKTICK, + CHAR_COMMA, + CHAR_DOT, + CHAR_LEFT_PARENTHESES, + CHAR_RIGHT_PARENTHESES, + CHAR_LEFT_CURLY_BRACE, + CHAR_RIGHT_CURLY_BRACE, + CHAR_LEFT_SQUARE_BRACKET, + CHAR_RIGHT_SQUARE_BRACKET, + CHAR_DOUBLE_QUOTE, + CHAR_SINGLE_QUOTE, + CHAR_NO_BREAK_SPACE, + CHAR_ZERO_WIDTH_NOBREAK_SPACE + } = require_constants2(); + var parse = (input, options = {}) => { + if (typeof input !== "string") { + throw new TypeError("Expected a string"); + } + let opts = options || {}; + let max = typeof opts.maxLength === "number" ? Math.min(MAX_LENGTH, opts.maxLength) : MAX_LENGTH; + if (input.length > max) { + throw new SyntaxError(`Input length (${input.length}), exceeds max characters (${max})`); + } + let ast = {type: "root", input, nodes: []}; + let stack = [ast]; + let block = ast; + let prev = ast; + let brackets = 0; + let length = input.length; + let index = 0; + let depth = 0; + let value; + let memo = {}; + const advance = () => input[index++]; + const push = (node) => { + if (node.type === "text" && prev.type === "dot") { + prev.type = "text"; + } + if (prev && prev.type === "text" && node.type === "text") { + prev.value += node.value; + return; + } + block.nodes.push(node); + node.parent = block; + node.prev = prev; + prev = node; + return node; + }; + push({type: "bos"}); + while (index < length) { + block = stack[stack.length - 1]; + value = advance(); + if (value === CHAR_ZERO_WIDTH_NOBREAK_SPACE || value === CHAR_NO_BREAK_SPACE) { + continue; + } + if (value === CHAR_BACKSLASH) { + push({type: "text", value: (options.keepEscaping ? value : "") + advance()}); + continue; + } + if (value === CHAR_RIGHT_SQUARE_BRACKET) { + push({type: "text", value: "\\" + value}); + continue; + } + if (value === CHAR_LEFT_SQUARE_BRACKET) { + brackets++; + let closed = true; + let next; + while (index < length && (next = advance())) { + value += next; + if (next === CHAR_LEFT_SQUARE_BRACKET) { + brackets++; + continue; + } + if (next === CHAR_BACKSLASH) { + value += advance(); + continue; + } + if (next === CHAR_RIGHT_SQUARE_BRACKET) { + brackets--; + if (brackets === 0) { + break; + } + } + } + push({type: "text", value}); + continue; + } + if (value === CHAR_LEFT_PARENTHESES) { + block = push({type: "paren", nodes: []}); + stack.push(block); + push({type: "text", value}); + continue; + } + if (value === CHAR_RIGHT_PARENTHESES) { + if (block.type !== "paren") { + push({type: "text", value}); + continue; + } + block = stack.pop(); + push({type: "text", value}); + block = stack[stack.length - 1]; + continue; + } + if (value === CHAR_DOUBLE_QUOTE || value === CHAR_SINGLE_QUOTE || value === CHAR_BACKTICK) { + let open = value; + let next; + if (options.keepQuotes !== true) { + value = ""; + } + while (index < length && (next = advance())) { + if (next === CHAR_BACKSLASH) { + value += next + advance(); + continue; + } + if (next === open) { + if (options.keepQuotes === true) + value += next; + break; + } + value += next; + } + push({type: "text", value}); + continue; + } + if (value === CHAR_LEFT_CURLY_BRACE) { + depth++; + let dollar = prev.value && prev.value.slice(-1) === "$" || block.dollar === true; + let brace = { + type: "brace", + open: true, + close: false, + dollar, + depth, + commas: 0, + ranges: 0, + nodes: [] + }; + block = push(brace); + stack.push(block); + push({type: "open", value}); + continue; + } + if (value === CHAR_RIGHT_CURLY_BRACE) { + if (block.type !== "brace") { + push({type: "text", value}); + continue; + } + let type = "close"; + block = stack.pop(); + block.close = true; + push({type, value}); + depth--; + block = stack[stack.length - 1]; + continue; + } + if (value === CHAR_COMMA && depth > 0) { + if (block.ranges > 0) { + block.ranges = 0; + let open = block.nodes.shift(); + block.nodes = [open, {type: "text", value: stringify(block)}]; + } + push({type: "comma", value}); + block.commas++; + continue; + } + if (value === CHAR_DOT && depth > 0 && block.commas === 0) { + let siblings = block.nodes; + if (depth === 0 || siblings.length === 0) { + push({type: "text", value}); + continue; + } + if (prev.type === "dot") { + block.range = []; + prev.value += value; + prev.type = "range"; + if (block.nodes.length !== 3 && block.nodes.length !== 5) { + block.invalid = true; + block.ranges = 0; + prev.type = "text"; + continue; + } + block.ranges++; + block.args = []; + continue; + } + if (prev.type === "range") { + siblings.pop(); + let before = siblings[siblings.length - 1]; + before.value += prev.value + value; + prev = before; + block.ranges--; + continue; + } + push({type: "dot", value}); + continue; + } + push({type: "text", value}); + } + do { + block = stack.pop(); + if (block.type !== "root") { + block.nodes.forEach((node) => { + if (!node.nodes) { + if (node.type === "open") + node.isOpen = true; + if (node.type === "close") + node.isClose = true; + if (!node.nodes) + node.type = "text"; + node.invalid = true; + } + }); + let parent = stack[stack.length - 1]; + let index2 = parent.nodes.indexOf(block); + parent.nodes.splice(index2, 1, ...block.nodes); + } + } while (stack.length > 0); + push({type: "eos"}); + return ast; + }; + module2.exports = parse; +}); + +// ../../node_modules/chokidar/node_modules/braces/index.js +var require_braces = __commonJS((exports2, module2) => { + "use strict"; + var stringify = require_stringify(); + var compile2 = require_compile(); + var expand = require_expand(); + var parse = require_parse2(); + var braces = (input, options = {}) => { + let output = []; + if (Array.isArray(input)) { + for (let pattern of input) { + let result = braces.create(pattern, options); + if (Array.isArray(result)) { + output.push(...result); + } else { + output.push(result); + } + } + } else { + output = [].concat(braces.create(input, options)); + } + if (options && options.expand === true && options.nodupes === true) { + output = [...new Set(output)]; + } + return output; + }; + braces.parse = (input, options = {}) => parse(input, options); + braces.stringify = (input, options = {}) => { + if (typeof input === "string") { + return stringify(braces.parse(input, options), options); + } + return stringify(input, options); + }; + braces.compile = (input, options = {}) => { + if (typeof input === "string") { + input = braces.parse(input, options); + } + return compile2(input, options); + }; + braces.expand = (input, options = {}) => { + if (typeof input === "string") { + input = braces.parse(input, options); + } + let result = expand(input, options); + if (options.noempty === true) { + result = result.filter(Boolean); + } + if (options.nodupes === true) { + result = [...new Set(result)]; + } + return result; + }; + braces.create = (input, options = {}) => { + if (input === "" || input.length < 3) { + return [input]; + } + return options.expand !== true ? braces.compile(input, options) : braces.expand(input, options); + }; + module2.exports = braces; +}); + +// ../../node_modules/binary-extensions/binary-extensions.json +var require_binary_extensions = __commonJS((exports2, module2) => { + module2.exports = [ + "3dm", + "3ds", + "3g2", + "3gp", + "7z", + "a", + "aac", + "adp", + "ai", + "aif", + "aiff", + "alz", + "ape", + "apk", + "ar", + "arj", + "asf", + "au", + "avi", + "bak", + "baml", + "bh", + "bin", + "bk", + "bmp", + "btif", + "bz2", + "bzip2", + "cab", + "caf", + "cgm", + "class", + "cmx", + "cpio", + "cr2", + "cur", + "dat", + "dcm", + "deb", + "dex", + "djvu", + "dll", + "dmg", + "dng", + "doc", + "docm", + "docx", + "dot", + "dotm", + "dra", + "DS_Store", + "dsk", + "dts", + "dtshd", + "dvb", + "dwg", + "dxf", + "ecelp4800", + "ecelp7470", + "ecelp9600", + "egg", + "eol", + "eot", + "epub", + "exe", + "f4v", + "fbs", + "fh", + "fla", + "flac", + "fli", + "flv", + "fpx", + "fst", + "fvt", + "g3", + "gh", + "gif", + "graffle", + "gz", + "gzip", + "h261", + "h263", + "h264", + "icns", + "ico", + "ief", + "img", + "ipa", + "iso", + "jar", + "jpeg", + "jpg", + "jpgv", + "jpm", + "jxr", + "key", + "ktx", + "lha", + "lib", + "lvp", + "lz", + "lzh", + "lzma", + "lzo", + "m3u", + "m4a", + "m4v", + "mar", + "mdi", + "mht", + "mid", + "midi", + "mj2", + "mka", + "mkv", + "mmr", + "mng", + "mobi", + "mov", + "movie", + "mp3", + "mp4", + "mp4a", + "mpeg", + "mpg", + "mpga", + "mxu", + "nef", + "npx", + "numbers", + "nupkg", + "o", + "oga", + "ogg", + "ogv", + "otf", + "pages", + "pbm", + "pcx", + "pdb", + "pdf", + "pea", + "pgm", + "pic", + "png", + "pnm", + "pot", + "potm", + "potx", + "ppa", + "ppam", + "ppm", + "pps", + "ppsm", + "ppsx", + "ppt", + "pptm", + "pptx", + "psd", + "pya", + "pyc", + "pyo", + "pyv", + "qt", + "rar", + "ras", + "raw", + "resources", + "rgb", + "rip", + "rlc", + "rmf", + "rmvb", + "rtf", + "rz", + "s3m", + "s7z", + "scpt", + "sgi", + "shar", + "sil", + "sketch", + "slk", + "smv", + "snk", + "so", + "stl", + "suo", + "sub", + "swf", + "tar", + "tbz", + "tbz2", + "tga", + "tgz", + "thmx", + "tif", + "tiff", + "tlz", + "ttc", + "ttf", + "txz", + "udf", + "uvh", + "uvi", + "uvm", + "uvp", + "uvs", + "uvu", + "viv", + "vob", + "war", + "wav", + "wax", + "wbmp", + "wdp", + "weba", + "webm", + "webp", + "whl", + "wim", + "wm", + "wma", + "wmv", + "wmx", + "woff", + "woff2", + "wrm", + "wvx", + "xbm", + "xif", + "xla", + "xlam", + "xls", + "xlsb", + "xlsm", + "xlsx", + "xlt", + "xltm", + "xltx", + "xm", + "xmind", + "xpi", + "xpm", + "xwd", + "xz", + "z", + "zip", + "zipx" + ]; +}); + +// ../../node_modules/binary-extensions/index.js +var require_binary_extensions2 = __commonJS((exports2, module2) => { + module2.exports = require_binary_extensions(); +}); + +// ../../node_modules/is-binary-path/index.js +var require_is_binary_path = __commonJS((exports2, module2) => { + "use strict"; + var path = require("path"); + var binaryExtensions = require_binary_extensions2(); + var extensions = new Set(binaryExtensions); + module2.exports = (filePath) => extensions.has(path.extname(filePath).slice(1).toLowerCase()); +}); + +// ../../node_modules/chokidar/lib/constants.js +var require_constants3 = __commonJS((exports2) => { + "use strict"; + var {sep} = require("path"); + var {platform} = process; + var os = require("os"); + exports2.EV_ALL = "all"; + exports2.EV_READY = "ready"; + exports2.EV_ADD = "add"; + exports2.EV_CHANGE = "change"; + exports2.EV_ADD_DIR = "addDir"; + exports2.EV_UNLINK = "unlink"; + exports2.EV_UNLINK_DIR = "unlinkDir"; + exports2.EV_RAW = "raw"; + exports2.EV_ERROR = "error"; + exports2.STR_DATA = "data"; + exports2.STR_END = "end"; + exports2.STR_CLOSE = "close"; + exports2.FSEVENT_CREATED = "created"; + exports2.FSEVENT_MODIFIED = "modified"; + exports2.FSEVENT_DELETED = "deleted"; + exports2.FSEVENT_MOVED = "moved"; + exports2.FSEVENT_CLONED = "cloned"; + exports2.FSEVENT_UNKNOWN = "unknown"; + exports2.FSEVENT_TYPE_FILE = "file"; + exports2.FSEVENT_TYPE_DIRECTORY = "directory"; + exports2.FSEVENT_TYPE_SYMLINK = "symlink"; + exports2.KEY_LISTENERS = "listeners"; + exports2.KEY_ERR = "errHandlers"; + exports2.KEY_RAW = "rawEmitters"; + exports2.HANDLER_KEYS = [exports2.KEY_LISTENERS, exports2.KEY_ERR, exports2.KEY_RAW]; + exports2.DOT_SLASH = `.${sep}`; + exports2.BACK_SLASH_RE = /\\/g; + exports2.DOUBLE_SLASH_RE = /\/\//; + exports2.SLASH_OR_BACK_SLASH_RE = /[/\\]/; + exports2.DOT_RE = /\..*\.(sw[px])$|~$|\.subl.*\.tmp/; + exports2.REPLACER_RE = /^\.[/\\]/; + exports2.SLASH = "/"; + exports2.SLASH_SLASH = "//"; + exports2.BRACE_START = "{"; + exports2.BANG = "!"; + exports2.ONE_DOT = "."; + exports2.TWO_DOTS = ".."; + exports2.STAR = "*"; + exports2.GLOBSTAR = "**"; + exports2.ROOT_GLOBSTAR = "/**/*"; + exports2.SLASH_GLOBSTAR = "/**"; + exports2.DIR_SUFFIX = "Dir"; + exports2.ANYMATCH_OPTS = {dot: true}; + exports2.STRING_TYPE = "string"; + exports2.FUNCTION_TYPE = "function"; + exports2.EMPTY_STR = ""; + exports2.EMPTY_FN = () => { + }; + exports2.IDENTITY_FN = (val) => val; + exports2.isWindows = platform === "win32"; + exports2.isMacos = platform === "darwin"; + exports2.isLinux = platform === "linux"; + exports2.isIBMi = os.type() === "OS400"; +}); + +// ../../node_modules/chokidar/lib/nodefs-handler.js +var require_nodefs_handler = __commonJS((exports2, module2) => { + "use strict"; + var fs = require("fs"); + var sysPath = require("path"); + var {promisify} = require("util"); + var isBinaryPath = require_is_binary_path(); + var { + isWindows, + isLinux, + EMPTY_FN, + EMPTY_STR, + KEY_LISTENERS, + KEY_ERR, + KEY_RAW, + HANDLER_KEYS, + EV_CHANGE, + EV_ADD, + EV_ADD_DIR, + EV_ERROR, + STR_DATA, + STR_END, + BRACE_START, + STAR + } = require_constants3(); + var THROTTLE_MODE_WATCH = "watch"; + var open = promisify(fs.open); + var stat = promisify(fs.stat); + var lstat = promisify(fs.lstat); + var close = promisify(fs.close); + var fsrealpath = promisify(fs.realpath); + var statMethods = {lstat, stat}; + var foreach = (val, fn) => { + if (val instanceof Set) { + val.forEach(fn); + } else { + fn(val); + } + }; + var addAndConvert = (main, prop, item) => { + let container = main[prop]; + if (!(container instanceof Set)) { + main[prop] = container = new Set([container]); + } + container.add(item); + }; + var clearItem = (cont) => (key) => { + const set = cont[key]; + if (set instanceof Set) { + set.clear(); + } else { + delete cont[key]; + } + }; + var delFromSet = (main, prop, item) => { + const container = main[prop]; + if (container instanceof Set) { + container.delete(item); + } else if (container === item) { + delete main[prop]; + } + }; + var isEmptySet = (val) => val instanceof Set ? val.size === 0 : !val; + var FsWatchInstances = new Map(); + function createFsWatchInstance(path, options, listener, errHandler, emitRaw) { + const handleEvent = (rawEvent, evPath) => { + listener(path); + emitRaw(rawEvent, evPath, {watchedPath: path}); + if (evPath && path !== evPath) { + fsWatchBroadcast(sysPath.resolve(path, evPath), KEY_LISTENERS, sysPath.join(path, evPath)); + } + }; + try { + return fs.watch(path, options, handleEvent); + } catch (error) { + errHandler(error); + } + } + var fsWatchBroadcast = (fullPath, type, val1, val2, val3) => { + const cont = FsWatchInstances.get(fullPath); + if (!cont) + return; + foreach(cont[type], (listener) => { + listener(val1, val2, val3); + }); + }; + var setFsWatchListener = (path, fullPath, options, handlers) => { + const {listener, errHandler, rawEmitter} = handlers; + let cont = FsWatchInstances.get(fullPath); + let watcher; + if (!options.persistent) { + watcher = createFsWatchInstance(path, options, listener, errHandler, rawEmitter); + return watcher.close.bind(watcher); + } + if (cont) { + addAndConvert(cont, KEY_LISTENERS, listener); + addAndConvert(cont, KEY_ERR, errHandler); + addAndConvert(cont, KEY_RAW, rawEmitter); + } else { + watcher = createFsWatchInstance(path, options, fsWatchBroadcast.bind(null, fullPath, KEY_LISTENERS), errHandler, fsWatchBroadcast.bind(null, fullPath, KEY_RAW)); + if (!watcher) + return; + watcher.on(EV_ERROR, async (error) => { + const broadcastErr = fsWatchBroadcast.bind(null, fullPath, KEY_ERR); + cont.watcherUnusable = true; + if (isWindows && error.code === "EPERM") { + try { + const fd = await open(path, "r"); + await close(fd); + broadcastErr(error); + } catch (err) { + } + } else { + broadcastErr(error); + } + }); + cont = { + listeners: listener, + errHandlers: errHandler, + rawEmitters: rawEmitter, + watcher + }; + FsWatchInstances.set(fullPath, cont); + } + return () => { + delFromSet(cont, KEY_LISTENERS, listener); + delFromSet(cont, KEY_ERR, errHandler); + delFromSet(cont, KEY_RAW, rawEmitter); + if (isEmptySet(cont.listeners)) { + cont.watcher.close(); + FsWatchInstances.delete(fullPath); + HANDLER_KEYS.forEach(clearItem(cont)); + cont.watcher = void 0; + Object.freeze(cont); + } + }; + }; + var FsWatchFileInstances = new Map(); + var setFsWatchFileListener = (path, fullPath, options, handlers) => { + const {listener, rawEmitter} = handlers; + let cont = FsWatchFileInstances.get(fullPath); + let listeners = new Set(); + let rawEmitters = new Set(); + const copts = cont && cont.options; + if (copts && (copts.persistent < options.persistent || copts.interval > options.interval)) { + listeners = cont.listeners; + rawEmitters = cont.rawEmitters; + fs.unwatchFile(fullPath); + cont = void 0; + } + if (cont) { + addAndConvert(cont, KEY_LISTENERS, listener); + addAndConvert(cont, KEY_RAW, rawEmitter); + } else { + cont = { + listeners: listener, + rawEmitters: rawEmitter, + options, + watcher: fs.watchFile(fullPath, options, (curr, prev) => { + foreach(cont.rawEmitters, (rawEmitter2) => { + rawEmitter2(EV_CHANGE, fullPath, {curr, prev}); + }); + const currmtime = curr.mtimeMs; + if (curr.size !== prev.size || currmtime > prev.mtimeMs || currmtime === 0) { + foreach(cont.listeners, (listener2) => listener2(path, curr)); + } + }) + }; + FsWatchFileInstances.set(fullPath, cont); + } + return () => { + delFromSet(cont, KEY_LISTENERS, listener); + delFromSet(cont, KEY_RAW, rawEmitter); + if (isEmptySet(cont.listeners)) { + FsWatchFileInstances.delete(fullPath); + fs.unwatchFile(fullPath); + cont.options = cont.watcher = void 0; + Object.freeze(cont); + } + }; + }; + var NodeFsHandler = class { + constructor(fsW) { + this.fsw = fsW; + this._boundHandleError = (error) => fsW._handleError(error); + } + _watchWithNodeFs(path, listener) { + const opts = this.fsw.options; + const directory = sysPath.dirname(path); + const basename = sysPath.basename(path); + const parent = this.fsw._getWatchedDir(directory); + parent.add(basename); + const absolutePath = sysPath.resolve(path); + const options = {persistent: opts.persistent}; + if (!listener) + listener = EMPTY_FN; + let closer; + if (opts.usePolling) { + options.interval = opts.enableBinaryInterval && isBinaryPath(basename) ? opts.binaryInterval : opts.interval; + closer = setFsWatchFileListener(path, absolutePath, options, { + listener, + rawEmitter: this.fsw._emitRaw + }); + } else { + closer = setFsWatchListener(path, absolutePath, options, { + listener, + errHandler: this._boundHandleError, + rawEmitter: this.fsw._emitRaw + }); + } + return closer; + } + _handleFile(file, stats, initialAdd) { + if (this.fsw.closed) { + return; + } + const dirname = sysPath.dirname(file); + const basename = sysPath.basename(file); + const parent = this.fsw._getWatchedDir(dirname); + let prevStats = stats; + if (parent.has(basename)) + return; + const listener = async (path, newStats) => { + if (!this.fsw._throttle(THROTTLE_MODE_WATCH, file, 5)) + return; + if (!newStats || newStats.mtimeMs === 0) { + try { + const newStats2 = await stat(file); + if (this.fsw.closed) + return; + const at = newStats2.atimeMs; + const mt = newStats2.mtimeMs; + if (!at || at <= mt || mt !== prevStats.mtimeMs) { + this.fsw._emit(EV_CHANGE, file, newStats2); + } + if (isLinux && prevStats.ino !== newStats2.ino) { + this.fsw._closeFile(path); + prevStats = newStats2; + this.fsw._addPathCloser(path, this._watchWithNodeFs(file, listener)); + } else { + prevStats = newStats2; + } + } catch (error) { + this.fsw._remove(dirname, basename); + } + } else if (parent.has(basename)) { + const at = newStats.atimeMs; + const mt = newStats.mtimeMs; + if (!at || at <= mt || mt !== prevStats.mtimeMs) { + this.fsw._emit(EV_CHANGE, file, newStats); + } + prevStats = newStats; + } + }; + const closer = this._watchWithNodeFs(file, listener); + if (!(initialAdd && this.fsw.options.ignoreInitial) && this.fsw._isntIgnored(file)) { + if (!this.fsw._throttle(EV_ADD, file, 0)) + return; + this.fsw._emit(EV_ADD, file, stats); + } + return closer; + } + async _handleSymlink(entry, directory, path, item) { + if (this.fsw.closed) { + return; + } + const full = entry.fullPath; + const dir = this.fsw._getWatchedDir(directory); + if (!this.fsw.options.followSymlinks) { + this.fsw._incrReadyCount(); + const linkPath = await fsrealpath(path); + if (this.fsw.closed) + return; + if (dir.has(item)) { + if (this.fsw._symlinkPaths.get(full) !== linkPath) { + this.fsw._symlinkPaths.set(full, linkPath); + this.fsw._emit(EV_CHANGE, path, entry.stats); + } + } else { + dir.add(item); + this.fsw._symlinkPaths.set(full, linkPath); + this.fsw._emit(EV_ADD, path, entry.stats); + } + this.fsw._emitReady(); + return true; + } + if (this.fsw._symlinkPaths.has(full)) { + return true; + } + this.fsw._symlinkPaths.set(full, true); + } + _handleRead(directory, initialAdd, wh, target, dir, depth, throttler) { + directory = sysPath.join(directory, EMPTY_STR); + if (!wh.hasGlob) { + throttler = this.fsw._throttle("readdir", directory, 1e3); + if (!throttler) + return; + } + const previous = this.fsw._getWatchedDir(wh.path); + const current = new Set(); + let stream = this.fsw._readdirp(directory, { + fileFilter: (entry) => wh.filterPath(entry), + directoryFilter: (entry) => wh.filterDir(entry), + depth: 0 + }).on(STR_DATA, async (entry) => { + if (this.fsw.closed) { + stream = void 0; + return; + } + const item = entry.path; + let path = sysPath.join(directory, item); + current.add(item); + if (entry.stats.isSymbolicLink() && await this._handleSymlink(entry, directory, path, item)) { + return; + } + if (this.fsw.closed) { + stream = void 0; + return; + } + if (item === target || !target && !previous.has(item)) { + this.fsw._incrReadyCount(); + path = sysPath.join(dir, sysPath.relative(dir, path)); + this._addToNodeFs(path, initialAdd, wh, depth + 1); + } + }).on(EV_ERROR, this._boundHandleError); + return new Promise((resolve) => stream.once(STR_END, () => { + if (this.fsw.closed) { + stream = void 0; + return; + } + const wasThrottled = throttler ? throttler.clear() : false; + resolve(); + previous.getChildren().filter((item) => { + return item !== directory && !current.has(item) && (!wh.hasGlob || wh.filterPath({ + fullPath: sysPath.resolve(directory, item) + })); + }).forEach((item) => { + this.fsw._remove(directory, item); + }); + stream = void 0; + if (wasThrottled) + this._handleRead(directory, false, wh, target, dir, depth, throttler); + })); + } + async _handleDir(dir, stats, initialAdd, depth, target, wh, realpath) { + const parentDir = this.fsw._getWatchedDir(sysPath.dirname(dir)); + const tracked = parentDir.has(sysPath.basename(dir)); + if (!(initialAdd && this.fsw.options.ignoreInitial) && !target && !tracked) { + if (!wh.hasGlob || wh.globFilter(dir)) + this.fsw._emit(EV_ADD_DIR, dir, stats); + } + parentDir.add(sysPath.basename(dir)); + this.fsw._getWatchedDir(dir); + let throttler; + let closer; + const oDepth = this.fsw.options.depth; + if ((oDepth == null || depth <= oDepth) && !this.fsw._symlinkPaths.has(realpath)) { + if (!target) { + await this._handleRead(dir, initialAdd, wh, target, dir, depth, throttler); + if (this.fsw.closed) + return; + } + closer = this._watchWithNodeFs(dir, (dirPath, stats2) => { + if (stats2 && stats2.mtimeMs === 0) + return; + this._handleRead(dirPath, false, wh, target, dir, depth, throttler); + }); + } + return closer; + } + async _addToNodeFs(path, initialAdd, priorWh, depth, target) { + const ready = this.fsw._emitReady; + if (this.fsw._isIgnored(path) || this.fsw.closed) { + ready(); + return false; + } + const wh = this.fsw._getWatchHelpers(path, depth); + if (!wh.hasGlob && priorWh) { + wh.hasGlob = priorWh.hasGlob; + wh.globFilter = priorWh.globFilter; + wh.filterPath = (entry) => priorWh.filterPath(entry); + wh.filterDir = (entry) => priorWh.filterDir(entry); + } + try { + const stats = await statMethods[wh.statMethod](wh.watchPath); + if (this.fsw.closed) + return; + if (this.fsw._isIgnored(wh.watchPath, stats)) { + ready(); + return false; + } + const follow = this.fsw.options.followSymlinks && !path.includes(STAR) && !path.includes(BRACE_START); + let closer; + if (stats.isDirectory()) { + const absPath = sysPath.resolve(path); + const targetPath = follow ? await fsrealpath(path) : path; + if (this.fsw.closed) + return; + closer = await this._handleDir(wh.watchPath, stats, initialAdd, depth, target, wh, targetPath); + if (this.fsw.closed) + return; + if (absPath !== targetPath && targetPath !== void 0) { + this.fsw._symlinkPaths.set(absPath, targetPath); + } + } else if (stats.isSymbolicLink()) { + const targetPath = follow ? await fsrealpath(path) : path; + if (this.fsw.closed) + return; + const parent = sysPath.dirname(wh.watchPath); + this.fsw._getWatchedDir(parent).add(wh.watchPath); + this.fsw._emit(EV_ADD, wh.watchPath, stats); + closer = await this._handleDir(parent, stats, initialAdd, depth, path, wh, targetPath); + if (this.fsw.closed) + return; + if (targetPath !== void 0) { + this.fsw._symlinkPaths.set(sysPath.resolve(path), targetPath); + } + } else { + closer = this._handleFile(wh.watchPath, stats, initialAdd); + } + ready(); + this.fsw._addPathCloser(path, closer); + return false; + } catch (error) { + if (this.fsw._handleError(error)) { + ready(); + return path; + } + } + } + }; + module2.exports = NodeFsHandler; +}); + +// ../../node_modules/chokidar/lib/fsevents-handler.js +var require_fsevents_handler = __commonJS((exports2, module2) => { + "use strict"; + var fs = require("fs"); + var sysPath = require("path"); + var {promisify} = require("util"); + var fsevents; + try { + fsevents = require("fsevents"); + } catch (error) { + if (process.env.CHOKIDAR_PRINT_FSEVENTS_REQUIRE_ERROR) + console.error(error); + } + if (fsevents) { + const mtch = process.version.match(/v(\d+)\.(\d+)/); + if (mtch && mtch[1] && mtch[2]) { + const maj = Number.parseInt(mtch[1], 10); + const min = Number.parseInt(mtch[2], 10); + if (maj === 8 && min < 16) { + fsevents = void 0; + } + } + } + var { + EV_ADD, + EV_CHANGE, + EV_ADD_DIR, + EV_UNLINK, + EV_ERROR, + STR_DATA, + STR_END, + FSEVENT_CREATED, + FSEVENT_MODIFIED, + FSEVENT_DELETED, + FSEVENT_MOVED, + FSEVENT_UNKNOWN, + FSEVENT_TYPE_FILE, + FSEVENT_TYPE_DIRECTORY, + FSEVENT_TYPE_SYMLINK, + ROOT_GLOBSTAR, + DIR_SUFFIX, + DOT_SLASH, + FUNCTION_TYPE, + EMPTY_FN, + IDENTITY_FN + } = require_constants3(); + var Depth = (value) => isNaN(value) ? {} : {depth: value}; + var stat = promisify(fs.stat); + var lstat = promisify(fs.lstat); + var realpath = promisify(fs.realpath); + var statMethods = {stat, lstat}; + var FSEventsWatchers = new Map(); + var consolidateThreshhold = 10; + var wrongEventFlags = new Set([ + 69888, + 70400, + 71424, + 72704, + 73472, + 131328, + 131840, + 262912 + ]); + var createFSEventsInstance = (path, callback) => { + const stop = fsevents.watch(path, callback); + return {stop}; + }; + function setFSEventsListener(path, realPath, listener, rawEmitter) { + let watchPath = sysPath.extname(realPath) ? sysPath.dirname(realPath) : realPath; + const parentPath = sysPath.dirname(watchPath); + let cont = FSEventsWatchers.get(watchPath); + if (couldConsolidate(parentPath)) { + watchPath = parentPath; + } + const resolvedPath = sysPath.resolve(path); + const hasSymlink = resolvedPath !== realPath; + const filteredListener = (fullPath, flags, info) => { + if (hasSymlink) + fullPath = fullPath.replace(realPath, resolvedPath); + if (fullPath === resolvedPath || !fullPath.indexOf(resolvedPath + sysPath.sep)) + listener(fullPath, flags, info); + }; + let watchedParent = false; + for (const watchedPath of FSEventsWatchers.keys()) { + if (realPath.indexOf(sysPath.resolve(watchedPath) + sysPath.sep) === 0) { + watchPath = watchedPath; + cont = FSEventsWatchers.get(watchPath); + watchedParent = true; + break; + } + } + if (cont || watchedParent) { + cont.listeners.add(filteredListener); + } else { + cont = { + listeners: new Set([filteredListener]), + rawEmitter, + watcher: createFSEventsInstance(watchPath, (fullPath, flags) => { + if (!cont.listeners.size) + return; + const info = fsevents.getInfo(fullPath, flags); + cont.listeners.forEach((list) => { + list(fullPath, flags, info); + }); + cont.rawEmitter(info.event, fullPath, info); + }) + }; + FSEventsWatchers.set(watchPath, cont); + } + return () => { + const lst = cont.listeners; + lst.delete(filteredListener); + if (!lst.size) { + FSEventsWatchers.delete(watchPath); + if (cont.watcher) + return cont.watcher.stop().then(() => { + cont.rawEmitter = cont.watcher = void 0; + Object.freeze(cont); + }); + } + }; + } + var couldConsolidate = (path) => { + let count = 0; + for (const watchPath of FSEventsWatchers.keys()) { + if (watchPath.indexOf(path) === 0) { + count++; + if (count >= consolidateThreshhold) { + return true; + } + } + } + return false; + }; + var canUse = () => fsevents && FSEventsWatchers.size < 128; + var calcDepth = (path, root) => { + let i = 0; + while (!path.indexOf(root) && (path = sysPath.dirname(path)) !== root) + i++; + return i; + }; + var sameTypes = (info, stats) => info.type === FSEVENT_TYPE_DIRECTORY && stats.isDirectory() || info.type === FSEVENT_TYPE_SYMLINK && stats.isSymbolicLink() || info.type === FSEVENT_TYPE_FILE && stats.isFile(); + var FsEventsHandler = class { + constructor(fsw) { + this.fsw = fsw; + } + checkIgnored(path, stats) { + const ipaths = this.fsw._ignoredPaths; + if (this.fsw._isIgnored(path, stats)) { + ipaths.add(path); + if (stats && stats.isDirectory()) { + ipaths.add(path + ROOT_GLOBSTAR); + } + return true; + } + ipaths.delete(path); + ipaths.delete(path + ROOT_GLOBSTAR); + } + addOrChange(path, fullPath, realPath, parent, watchedDir, item, info, opts) { + const event = watchedDir.has(item) ? EV_CHANGE : EV_ADD; + this.handleEvent(event, path, fullPath, realPath, parent, watchedDir, item, info, opts); + } + async checkExists(path, fullPath, realPath, parent, watchedDir, item, info, opts) { + try { + const stats = await stat(path); + if (this.fsw.closed) + return; + if (sameTypes(info, stats)) { + this.addOrChange(path, fullPath, realPath, parent, watchedDir, item, info, opts); + } else { + this.handleEvent(EV_UNLINK, path, fullPath, realPath, parent, watchedDir, item, info, opts); + } + } catch (error) { + if (error.code === "EACCES") { + this.addOrChange(path, fullPath, realPath, parent, watchedDir, item, info, opts); + } else { + this.handleEvent(EV_UNLINK, path, fullPath, realPath, parent, watchedDir, item, info, opts); + } + } + } + handleEvent(event, path, fullPath, realPath, parent, watchedDir, item, info, opts) { + if (this.fsw.closed || this.checkIgnored(path)) + return; + if (event === EV_UNLINK) { + const isDirectory = info.type === FSEVENT_TYPE_DIRECTORY; + if (isDirectory || watchedDir.has(item)) { + this.fsw._remove(parent, item, isDirectory); + } + } else { + if (event === EV_ADD) { + if (info.type === FSEVENT_TYPE_DIRECTORY) + this.fsw._getWatchedDir(path); + if (info.type === FSEVENT_TYPE_SYMLINK && opts.followSymlinks) { + const curDepth = opts.depth === void 0 ? void 0 : calcDepth(fullPath, realPath) + 1; + return this._addToFsEvents(path, false, true, curDepth); + } + this.fsw._getWatchedDir(parent).add(item); + } + const eventName = info.type === FSEVENT_TYPE_DIRECTORY ? event + DIR_SUFFIX : event; + this.fsw._emit(eventName, path); + if (eventName === EV_ADD_DIR) + this._addToFsEvents(path, false, true); + } + } + _watchWithFsEvents(watchPath, realPath, transform, globFilter) { + if (this.fsw.closed || this.fsw._isIgnored(watchPath)) + return; + const opts = this.fsw.options; + const watchCallback = async (fullPath, flags, info) => { + if (this.fsw.closed) + return; + if (opts.depth !== void 0 && calcDepth(fullPath, realPath) > opts.depth) + return; + const path = transform(sysPath.join(watchPath, sysPath.relative(watchPath, fullPath))); + if (globFilter && !globFilter(path)) + return; + const parent = sysPath.dirname(path); + const item = sysPath.basename(path); + const watchedDir = this.fsw._getWatchedDir(info.type === FSEVENT_TYPE_DIRECTORY ? path : parent); + if (wrongEventFlags.has(flags) || info.event === FSEVENT_UNKNOWN) { + if (typeof opts.ignored === FUNCTION_TYPE) { + let stats; + try { + stats = await stat(path); + } catch (error) { + } + if (this.fsw.closed) + return; + if (this.checkIgnored(path, stats)) + return; + if (sameTypes(info, stats)) { + this.addOrChange(path, fullPath, realPath, parent, watchedDir, item, info, opts); + } else { + this.handleEvent(EV_UNLINK, path, fullPath, realPath, parent, watchedDir, item, info, opts); + } + } else { + this.checkExists(path, fullPath, realPath, parent, watchedDir, item, info, opts); + } + } else { + switch (info.event) { + case FSEVENT_CREATED: + case FSEVENT_MODIFIED: + return this.addOrChange(path, fullPath, realPath, parent, watchedDir, item, info, opts); + case FSEVENT_DELETED: + case FSEVENT_MOVED: + return this.checkExists(path, fullPath, realPath, parent, watchedDir, item, info, opts); + } + } + }; + const closer = setFSEventsListener(watchPath, realPath, watchCallback, this.fsw._emitRaw); + this.fsw._emitReady(); + return closer; + } + async _handleFsEventsSymlink(linkPath, fullPath, transform, curDepth) { + if (this.fsw.closed || this.fsw._symlinkPaths.has(fullPath)) + return; + this.fsw._symlinkPaths.set(fullPath, true); + this.fsw._incrReadyCount(); + try { + const linkTarget = await realpath(linkPath); + if (this.fsw.closed) + return; + if (this.fsw._isIgnored(linkTarget)) { + return this.fsw._emitReady(); + } + this.fsw._incrReadyCount(); + this._addToFsEvents(linkTarget || linkPath, (path) => { + let aliasedPath = linkPath; + if (linkTarget && linkTarget !== DOT_SLASH) { + aliasedPath = path.replace(linkTarget, linkPath); + } else if (path !== DOT_SLASH) { + aliasedPath = sysPath.join(linkPath, path); + } + return transform(aliasedPath); + }, false, curDepth); + } catch (error) { + if (this.fsw._handleError(error)) { + return this.fsw._emitReady(); + } + } + } + emitAdd(newPath, stats, processPath, opts, forceAdd) { + const pp = processPath(newPath); + const isDir = stats.isDirectory(); + const dirObj = this.fsw._getWatchedDir(sysPath.dirname(pp)); + const base = sysPath.basename(pp); + if (isDir) + this.fsw._getWatchedDir(pp); + if (dirObj.has(base)) + return; + dirObj.add(base); + if (!opts.ignoreInitial || forceAdd === true) { + this.fsw._emit(isDir ? EV_ADD_DIR : EV_ADD, pp, stats); + } + } + initWatch(realPath, path, wh, processPath) { + if (this.fsw.closed) + return; + const closer = this._watchWithFsEvents(wh.watchPath, sysPath.resolve(realPath || wh.watchPath), processPath, wh.globFilter); + this.fsw._addPathCloser(path, closer); + } + async _addToFsEvents(path, transform, forceAdd, priorDepth) { + if (this.fsw.closed) { + return; + } + const opts = this.fsw.options; + const processPath = typeof transform === FUNCTION_TYPE ? transform : IDENTITY_FN; + const wh = this.fsw._getWatchHelpers(path); + try { + const stats = await statMethods[wh.statMethod](wh.watchPath); + if (this.fsw.closed) + return; + if (this.fsw._isIgnored(wh.watchPath, stats)) { + throw null; + } + if (stats.isDirectory()) { + if (!wh.globFilter) + this.emitAdd(processPath(path), stats, processPath, opts, forceAdd); + if (priorDepth && priorDepth > opts.depth) + return; + this.fsw._readdirp(wh.watchPath, { + fileFilter: (entry) => wh.filterPath(entry), + directoryFilter: (entry) => wh.filterDir(entry), + ...Depth(opts.depth - (priorDepth || 0)) + }).on(STR_DATA, (entry) => { + if (this.fsw.closed) { + return; + } + if (entry.stats.isDirectory() && !wh.filterPath(entry)) + return; + const joinedPath = sysPath.join(wh.watchPath, entry.path); + const {fullPath} = entry; + if (wh.followSymlinks && entry.stats.isSymbolicLink()) { + const curDepth = opts.depth === void 0 ? void 0 : calcDepth(joinedPath, sysPath.resolve(wh.watchPath)) + 1; + this._handleFsEventsSymlink(joinedPath, fullPath, processPath, curDepth); + } else { + this.emitAdd(joinedPath, entry.stats, processPath, opts, forceAdd); + } + }).on(EV_ERROR, EMPTY_FN).on(STR_END, () => { + this.fsw._emitReady(); + }); + } else { + this.emitAdd(wh.watchPath, stats, processPath, opts, forceAdd); + this.fsw._emitReady(); + } + } catch (error) { + if (!error || this.fsw._handleError(error)) { + this.fsw._emitReady(); + this.fsw._emitReady(); + } + } + if (opts.persistent && forceAdd !== true) { + if (typeof transform === FUNCTION_TYPE) { + this.initWatch(void 0, path, wh, processPath); + } else { + let realPath; + try { + realPath = await realpath(wh.watchPath); + } catch (e2) { + } + this.initWatch(realPath, path, wh, processPath); + } + } + } + }; + module2.exports = FsEventsHandler; + module2.exports.canUse = canUse; +}); + +// ../../node_modules/chokidar/index.js +var require_chokidar = __commonJS((exports2) => { + "use strict"; + var {EventEmitter} = require("events"); + var fs = require("fs"); + var sysPath = require("path"); + var {promisify} = require("util"); + var readdirp = require_readdirp(); + var anymatch = require_anymatch().default; + var globParent = require_glob_parent(); + var isGlob = require_is_glob(); + var braces = require_braces(); + var normalizePath = require_normalize_path(); + var NodeFsHandler = require_nodefs_handler(); + var FsEventsHandler = require_fsevents_handler(); + var { + EV_ALL, + EV_READY, + EV_ADD, + EV_CHANGE, + EV_UNLINK, + EV_ADD_DIR, + EV_UNLINK_DIR, + EV_RAW, + EV_ERROR, + STR_CLOSE, + STR_END, + BACK_SLASH_RE, + DOUBLE_SLASH_RE, + SLASH_OR_BACK_SLASH_RE, + DOT_RE, + REPLACER_RE, + SLASH, + SLASH_SLASH, + BRACE_START, + BANG, + ONE_DOT, + TWO_DOTS, + GLOBSTAR, + SLASH_GLOBSTAR, + ANYMATCH_OPTS, + STRING_TYPE, + FUNCTION_TYPE, + EMPTY_STR, + EMPTY_FN, + isWindows, + isMacos, + isIBMi + } = require_constants3(); + var stat = promisify(fs.stat); + var readdir = promisify(fs.readdir); + var arrify = (value = []) => Array.isArray(value) ? value : [value]; + var flatten = (list, result = []) => { + list.forEach((item) => { + if (Array.isArray(item)) { + flatten(item, result); + } else { + result.push(item); + } + }); + return result; + }; + var unifyPaths = (paths_) => { + const paths = flatten(arrify(paths_)); + if (!paths.every((p) => typeof p === STRING_TYPE)) { + throw new TypeError(`Non-string provided as watch path: ${paths}`); + } + return paths.map(normalizePathToUnix); + }; + var toUnix = (string) => { + let str = string.replace(BACK_SLASH_RE, SLASH); + let prepend = false; + if (str.startsWith(SLASH_SLASH)) { + prepend = true; + } + while (str.match(DOUBLE_SLASH_RE)) { + str = str.replace(DOUBLE_SLASH_RE, SLASH); + } + if (prepend) { + str = SLASH + str; + } + return str; + }; + var normalizePathToUnix = (path) => toUnix(sysPath.normalize(toUnix(path))); + var normalizeIgnored = (cwd = EMPTY_STR) => (path) => { + if (typeof path !== STRING_TYPE) + return path; + return normalizePathToUnix(sysPath.isAbsolute(path) ? path : sysPath.join(cwd, path)); + }; + var getAbsolutePath = (path, cwd) => { + if (sysPath.isAbsolute(path)) { + return path; + } + if (path.startsWith(BANG)) { + return BANG + sysPath.join(cwd, path.slice(1)); + } + return sysPath.join(cwd, path); + }; + var undef = (opts, key) => opts[key] === void 0; + var DirEntry = class { + constructor(dir, removeWatcher) { + this.path = dir; + this._removeWatcher = removeWatcher; + this.items = new Set(); + } + add(item) { + const {items} = this; + if (!items) + return; + if (item !== ONE_DOT && item !== TWO_DOTS) + items.add(item); + } + async remove(item) { + const {items} = this; + if (!items) + return; + items.delete(item); + if (items.size > 0) + return; + const dir = this.path; + try { + await readdir(dir); + } catch (err) { + if (this._removeWatcher) { + this._removeWatcher(sysPath.dirname(dir), sysPath.basename(dir)); + } + } + } + has(item) { + const {items} = this; + if (!items) + return; + return items.has(item); + } + getChildren() { + const {items} = this; + if (!items) + return; + return [...items.values()]; + } + dispose() { + this.items.clear(); + delete this.path; + delete this._removeWatcher; + delete this.items; + Object.freeze(this); + } + }; + var STAT_METHOD_F = "stat"; + var STAT_METHOD_L = "lstat"; + var WatchHelper = class { + constructor(path, watchPath, follow, fsw) { + this.fsw = fsw; + this.path = path = path.replace(REPLACER_RE, EMPTY_STR); + this.watchPath = watchPath; + this.fullWatchPath = sysPath.resolve(watchPath); + this.hasGlob = watchPath !== path; + if (path === EMPTY_STR) + this.hasGlob = false; + this.globSymlink = this.hasGlob && follow ? void 0 : false; + this.globFilter = this.hasGlob ? anymatch(path, void 0, ANYMATCH_OPTS) : false; + this.dirParts = this.getDirParts(path); + this.dirParts.forEach((parts) => { + if (parts.length > 1) + parts.pop(); + }); + this.followSymlinks = follow; + this.statMethod = follow ? STAT_METHOD_F : STAT_METHOD_L; + } + checkGlobSymlink(entry) { + if (this.globSymlink === void 0) { + this.globSymlink = entry.fullParentDir === this.fullWatchPath ? false : {realPath: entry.fullParentDir, linkPath: this.fullWatchPath}; + } + if (this.globSymlink) { + return entry.fullPath.replace(this.globSymlink.realPath, this.globSymlink.linkPath); + } + return entry.fullPath; + } + entryPath(entry) { + return sysPath.join(this.watchPath, sysPath.relative(this.watchPath, this.checkGlobSymlink(entry))); + } + filterPath(entry) { + const {stats} = entry; + if (stats && stats.isSymbolicLink()) + return this.filterDir(entry); + const resolvedPath = this.entryPath(entry); + const matchesGlob = this.hasGlob && typeof this.globFilter === FUNCTION_TYPE ? this.globFilter(resolvedPath) : true; + return matchesGlob && this.fsw._isntIgnored(resolvedPath, stats) && this.fsw._hasReadPermissions(stats); + } + getDirParts(path) { + if (!this.hasGlob) + return []; + const parts = []; + const expandedPath = path.includes(BRACE_START) ? braces.expand(path) : [path]; + expandedPath.forEach((path2) => { + parts.push(sysPath.relative(this.watchPath, path2).split(SLASH_OR_BACK_SLASH_RE)); + }); + return parts; + } + filterDir(entry) { + if (this.hasGlob) { + const entryParts = this.getDirParts(this.checkGlobSymlink(entry)); + let globstar = false; + this.unmatchedGlob = !this.dirParts.some((parts) => { + return parts.every((part, i) => { + if (part === GLOBSTAR) + globstar = true; + return globstar || !entryParts[0][i] || anymatch(part, entryParts[0][i], ANYMATCH_OPTS); + }); + }); + } + return !this.unmatchedGlob && this.fsw._isntIgnored(this.entryPath(entry), entry.stats); + } + }; + var FSWatcher = class extends EventEmitter { + constructor(_opts) { + super(); + const opts = {}; + if (_opts) + Object.assign(opts, _opts); + this._watched = new Map(); + this._closers = new Map(); + this._ignoredPaths = new Set(); + this._throttled = new Map(); + this._symlinkPaths = new Map(); + this._streams = new Set(); + this.closed = false; + if (undef(opts, "persistent")) + opts.persistent = true; + if (undef(opts, "ignoreInitial")) + opts.ignoreInitial = false; + if (undef(opts, "ignorePermissionErrors")) + opts.ignorePermissionErrors = false; + if (undef(opts, "interval")) + opts.interval = 100; + if (undef(opts, "binaryInterval")) + opts.binaryInterval = 300; + if (undef(opts, "disableGlobbing")) + opts.disableGlobbing = false; + opts.enableBinaryInterval = opts.binaryInterval !== opts.interval; + if (undef(opts, "useFsEvents")) + opts.useFsEvents = !opts.usePolling; + const canUseFsEvents = FsEventsHandler.canUse(); + if (!canUseFsEvents) + opts.useFsEvents = false; + if (undef(opts, "usePolling") && !opts.useFsEvents) { + opts.usePolling = isMacos; + } + if (isIBMi) { + opts.usePolling = true; + } + const envPoll = process.env.CHOKIDAR_USEPOLLING; + if (envPoll !== void 0) { + const envLower = envPoll.toLowerCase(); + if (envLower === "false" || envLower === "0") { + opts.usePolling = false; + } else if (envLower === "true" || envLower === "1") { + opts.usePolling = true; + } else { + opts.usePolling = !!envLower; + } + } + const envInterval = process.env.CHOKIDAR_INTERVAL; + if (envInterval) { + opts.interval = Number.parseInt(envInterval, 10); + } + if (undef(opts, "atomic")) + opts.atomic = !opts.usePolling && !opts.useFsEvents; + if (opts.atomic) + this._pendingUnlinks = new Map(); + if (undef(opts, "followSymlinks")) + opts.followSymlinks = true; + if (undef(opts, "awaitWriteFinish")) + opts.awaitWriteFinish = false; + if (opts.awaitWriteFinish === true) + opts.awaitWriteFinish = {}; + const awf = opts.awaitWriteFinish; + if (awf) { + if (!awf.stabilityThreshold) + awf.stabilityThreshold = 2e3; + if (!awf.pollInterval) + awf.pollInterval = 100; + this._pendingWrites = new Map(); + } + if (opts.ignored) + opts.ignored = arrify(opts.ignored); + let readyCalls = 0; + this._emitReady = () => { + readyCalls++; + if (readyCalls >= this._readyCount) { + this._emitReady = EMPTY_FN; + this._readyEmitted = true; + process.nextTick(() => this.emit(EV_READY)); + } + }; + this._emitRaw = (...args) => this.emit(EV_RAW, ...args); + this._readyEmitted = false; + this.options = opts; + if (opts.useFsEvents) { + this._fsEventsHandler = new FsEventsHandler(this); + } else { + this._nodeFsHandler = new NodeFsHandler(this); + } + Object.freeze(opts); + } + add(paths_, _origAdd, _internal) { + const {cwd, disableGlobbing} = this.options; + this.closed = false; + let paths = unifyPaths(paths_); + if (cwd) { + paths = paths.map((path) => { + const absPath = getAbsolutePath(path, cwd); + if (disableGlobbing || !isGlob(path)) { + return absPath; + } + return normalizePath(absPath); + }); + } + paths = paths.filter((path) => { + if (path.startsWith(BANG)) { + this._ignoredPaths.add(path.slice(1)); + return false; + } + this._ignoredPaths.delete(path); + this._ignoredPaths.delete(path + SLASH_GLOBSTAR); + this._userIgnored = void 0; + return true; + }); + if (this.options.useFsEvents && this._fsEventsHandler) { + if (!this._readyCount) + this._readyCount = paths.length; + if (this.options.persistent) + this._readyCount *= 2; + paths.forEach((path) => this._fsEventsHandler._addToFsEvents(path)); + } else { + if (!this._readyCount) + this._readyCount = 0; + this._readyCount += paths.length; + Promise.all(paths.map(async (path) => { + const res = await this._nodeFsHandler._addToNodeFs(path, !_internal, 0, 0, _origAdd); + if (res) + this._emitReady(); + return res; + })).then((results) => { + if (this.closed) + return; + results.filter((item) => item).forEach((item) => { + this.add(sysPath.dirname(item), sysPath.basename(_origAdd || item)); + }); + }); + } + return this; + } + unwatch(paths_) { + if (this.closed) + return this; + const paths = unifyPaths(paths_); + const {cwd} = this.options; + paths.forEach((path) => { + if (!sysPath.isAbsolute(path) && !this._closers.has(path)) { + if (cwd) + path = sysPath.join(cwd, path); + path = sysPath.resolve(path); + } + this._closePath(path); + this._ignoredPaths.add(path); + if (this._watched.has(path)) { + this._ignoredPaths.add(path + SLASH_GLOBSTAR); + } + this._userIgnored = void 0; + }); + return this; + } + close() { + if (this.closed) + return this._closePromise; + this.closed = true; + this.removeAllListeners(); + const closers = []; + this._closers.forEach((closerList) => closerList.forEach((closer) => { + const promise = closer(); + if (promise instanceof Promise) + closers.push(promise); + })); + this._streams.forEach((stream) => stream.destroy()); + this._userIgnored = void 0; + this._readyCount = 0; + this._readyEmitted = false; + this._watched.forEach((dirent) => dirent.dispose()); + ["closers", "watched", "streams", "symlinkPaths", "throttled"].forEach((key) => { + this[`_${key}`].clear(); + }); + this._closePromise = closers.length ? Promise.all(closers).then(() => void 0) : Promise.resolve(); + return this._closePromise; + } + getWatched() { + const watchList = {}; + this._watched.forEach((entry, dir) => { + const key = this.options.cwd ? sysPath.relative(this.options.cwd, dir) : dir; + watchList[key || ONE_DOT] = entry.getChildren().sort(); + }); + return watchList; + } + emitWithAll(event, args) { + this.emit(...args); + if (event !== EV_ERROR) + this.emit(EV_ALL, ...args); + } + async _emit(event, path, val1, val2, val3) { + if (this.closed) + return; + const opts = this.options; + if (isWindows) + path = sysPath.normalize(path); + if (opts.cwd) + path = sysPath.relative(opts.cwd, path); + const args = [event, path]; + if (val3 !== void 0) + args.push(val1, val2, val3); + else if (val2 !== void 0) + args.push(val1, val2); + else if (val1 !== void 0) + args.push(val1); + const awf = opts.awaitWriteFinish; + let pw; + if (awf && (pw = this._pendingWrites.get(path))) { + pw.lastChange = new Date(); + return this; + } + if (opts.atomic) { + if (event === EV_UNLINK) { + this._pendingUnlinks.set(path, args); + setTimeout(() => { + this._pendingUnlinks.forEach((entry, path2) => { + this.emit(...entry); + this.emit(EV_ALL, ...entry); + this._pendingUnlinks.delete(path2); + }); + }, typeof opts.atomic === "number" ? opts.atomic : 100); + return this; + } + if (event === EV_ADD && this._pendingUnlinks.has(path)) { + event = args[0] = EV_CHANGE; + this._pendingUnlinks.delete(path); + } + } + if (awf && (event === EV_ADD || event === EV_CHANGE) && this._readyEmitted) { + const awfEmit = (err, stats) => { + if (err) { + event = args[0] = EV_ERROR; + args[1] = err; + this.emitWithAll(event, args); + } else if (stats) { + if (args.length > 2) { + args[2] = stats; + } else { + args.push(stats); + } + this.emitWithAll(event, args); + } + }; + this._awaitWriteFinish(path, awf.stabilityThreshold, event, awfEmit); + return this; + } + if (event === EV_CHANGE) { + const isThrottled = !this._throttle(EV_CHANGE, path, 50); + if (isThrottled) + return this; + } + if (opts.alwaysStat && val1 === void 0 && (event === EV_ADD || event === EV_ADD_DIR || event === EV_CHANGE)) { + const fullPath = opts.cwd ? sysPath.join(opts.cwd, path) : path; + let stats; + try { + stats = await stat(fullPath); + } catch (err) { + } + if (!stats || this.closed) + return; + args.push(stats); + } + this.emitWithAll(event, args); + return this; + } + _handleError(error) { + const code = error && error.code; + if (error && code !== "ENOENT" && code !== "ENOTDIR" && (!this.options.ignorePermissionErrors || code !== "EPERM" && code !== "EACCES")) { + this.emit(EV_ERROR, error); + } + return error || this.closed; + } + _throttle(actionType, path, timeout) { + if (!this._throttled.has(actionType)) { + this._throttled.set(actionType, new Map()); + } + const action = this._throttled.get(actionType); + const actionPath = action.get(path); + if (actionPath) { + actionPath.count++; + return false; + } + let timeoutObject; + const clear = () => { + const item = action.get(path); + const count = item ? item.count : 0; + action.delete(path); + clearTimeout(timeoutObject); + if (item) + clearTimeout(item.timeoutObject); + return count; + }; + timeoutObject = setTimeout(clear, timeout); + const thr = {timeoutObject, clear, count: 0}; + action.set(path, thr); + return thr; + } + _incrReadyCount() { + return this._readyCount++; + } + _awaitWriteFinish(path, threshold, event, awfEmit) { + let timeoutHandler; + let fullPath = path; + if (this.options.cwd && !sysPath.isAbsolute(path)) { + fullPath = sysPath.join(this.options.cwd, path); + } + const now = new Date(); + const awaitWriteFinish = (prevStat) => { + fs.stat(fullPath, (err, curStat) => { + if (err || !this._pendingWrites.has(path)) { + if (err && err.code !== "ENOENT") + awfEmit(err); + return; + } + const now2 = Number(new Date()); + if (prevStat && curStat.size !== prevStat.size) { + this._pendingWrites.get(path).lastChange = now2; + } + const pw = this._pendingWrites.get(path); + const df = now2 - pw.lastChange; + if (df >= threshold) { + this._pendingWrites.delete(path); + awfEmit(void 0, curStat); + } else { + timeoutHandler = setTimeout(awaitWriteFinish, this.options.awaitWriteFinish.pollInterval, curStat); + } + }); + }; + if (!this._pendingWrites.has(path)) { + this._pendingWrites.set(path, { + lastChange: now, + cancelWait: () => { + this._pendingWrites.delete(path); + clearTimeout(timeoutHandler); + return event; + } + }); + timeoutHandler = setTimeout(awaitWriteFinish, this.options.awaitWriteFinish.pollInterval); + } + } + _getGlobIgnored() { + return [...this._ignoredPaths.values()]; + } + _isIgnored(path, stats) { + if (this.options.atomic && DOT_RE.test(path)) + return true; + if (!this._userIgnored) { + const {cwd} = this.options; + const ign = this.options.ignored; + const ignored = ign && ign.map(normalizeIgnored(cwd)); + const paths = arrify(ignored).filter((path2) => typeof path2 === STRING_TYPE && !isGlob(path2)).map((path2) => path2 + SLASH_GLOBSTAR); + const list = this._getGlobIgnored().map(normalizeIgnored(cwd)).concat(ignored, paths); + this._userIgnored = anymatch(list, void 0, ANYMATCH_OPTS); + } + return this._userIgnored([path, stats]); + } + _isntIgnored(path, stat2) { + return !this._isIgnored(path, stat2); + } + _getWatchHelpers(path, depth) { + const watchPath = depth || this.options.disableGlobbing || !isGlob(path) ? path : globParent(path); + const follow = this.options.followSymlinks; + return new WatchHelper(path, watchPath, follow, this); + } + _getWatchedDir(directory) { + if (!this._boundRemove) + this._boundRemove = this._remove.bind(this); + const dir = sysPath.resolve(directory); + if (!this._watched.has(dir)) + this._watched.set(dir, new DirEntry(dir, this._boundRemove)); + return this._watched.get(dir); + } + _hasReadPermissions(stats) { + if (this.options.ignorePermissionErrors) + return true; + const md = stats && Number.parseInt(stats.mode, 10); + const st = md & 511; + const it = Number.parseInt(st.toString(8)[0], 10); + return Boolean(4 & it); + } + _remove(directory, item, isDirectory) { + const path = sysPath.join(directory, item); + const fullPath = sysPath.resolve(path); + isDirectory = isDirectory != null ? isDirectory : this._watched.has(path) || this._watched.has(fullPath); + if (!this._throttle("remove", path, 100)) + return; + if (!isDirectory && !this.options.useFsEvents && this._watched.size === 1) { + this.add(directory, item, true); + } + const wp = this._getWatchedDir(path); + const nestedDirectoryChildren = wp.getChildren(); + nestedDirectoryChildren.forEach((nested) => this._remove(path, nested)); + const parent = this._getWatchedDir(directory); + const wasTracked = parent.has(item); + parent.remove(item); + if (this._symlinkPaths.has(fullPath)) { + this._symlinkPaths.delete(fullPath); + } + let relPath = path; + if (this.options.cwd) + relPath = sysPath.relative(this.options.cwd, path); + if (this.options.awaitWriteFinish && this._pendingWrites.has(relPath)) { + const event = this._pendingWrites.get(relPath).cancelWait(); + if (event === EV_ADD) + return; + } + this._watched.delete(path); + this._watched.delete(fullPath); + const eventName = isDirectory ? EV_UNLINK_DIR : EV_UNLINK; + if (wasTracked && !this._isIgnored(path)) + this._emit(eventName, path); + if (!this.options.useFsEvents) { + this._closePath(path); + } + } + _closePath(path) { + this._closeFile(path); + const dir = sysPath.dirname(path); + this._getWatchedDir(dir).remove(sysPath.basename(path)); + } + _closeFile(path) { + const closers = this._closers.get(path); + if (!closers) + return; + closers.forEach((closer) => closer()); + this._closers.delete(path); + } + _addPathCloser(path, closer) { + if (!closer) + return; + let list = this._closers.get(path); + if (!list) { + list = []; + this._closers.set(path, list); + } + list.push(closer); + } + _readdirp(root, opts) { + if (this.closed) + return; + const options = {type: EV_ALL, alwaysStat: true, lstat: true, ...opts}; + let stream = readdirp(root, options); + this._streams.add(stream); + stream.once(STR_CLOSE, () => { + stream = void 0; + }); + stream.once(STR_END, () => { + if (stream) { + this._streams.delete(stream); + stream = void 0; + } + }); + return stream; + } + }; + exports2.FSWatcher = FSWatcher; + var watch = (paths, options) => { + const watcher = new FSWatcher(options); + watcher.add(paths); + return watcher; + }; + exports2.watch = watch; +}); + +// ../../node_modules/nunjucks/src/node-loaders.js +var require_node_loaders = __commonJS((exports2, module2) => { + "use strict"; + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + _setPrototypeOf(subClass, superClass); + } + function _setPrototypeOf(o, p) { + _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf2(o2, p2) { + o2.__proto__ = p2; + return o2; + }; + return _setPrototypeOf(o, p); + } + var fs = require("fs"); + var path = require("path"); + var Loader2 = require_loader(); + var _require2 = require_precompiled_loader(); + var PrecompiledLoader = _require2.PrecompiledLoader; + var chokidar; + var FileSystemLoader = /* @__PURE__ */ function(_Loader) { + _inheritsLoose(FileSystemLoader2, _Loader); + function FileSystemLoader2(searchPaths, opts) { + var _this; + _this = _Loader.call(this) || this; + if (typeof opts === "boolean") { + console.log("[nunjucks] Warning: you passed a boolean as the second argument to FileSystemLoader, but it now takes an options object. See http://mozilla.github.io/nunjucks/api.html#filesystemloader"); + } + opts = opts || {}; + _this.pathsToNames = {}; + _this.noCache = !!opts.noCache; + if (searchPaths) { + searchPaths = Array.isArray(searchPaths) ? searchPaths : [searchPaths]; + _this.searchPaths = searchPaths.map(path.normalize); + } else { + _this.searchPaths = ["."]; + } + if (opts.watch) { + try { + chokidar = require_chokidar(); + } catch (e2) { + throw new Error("watch requires chokidar to be installed"); + } + var paths = _this.searchPaths.filter(fs.existsSync); + var watcher = chokidar.watch(paths); + watcher.on("all", function(event, fullname) { + fullname = path.resolve(fullname); + if (event === "change" && fullname in _this.pathsToNames) { + _this.emit("update", _this.pathsToNames[fullname], fullname); + } + }); + watcher.on("error", function(error) { + console.log("Watcher error: " + error); + }); + } + return _this; + } + var _proto = FileSystemLoader2.prototype; + _proto.getSource = function getSource(name) { + var fullpath = null; + var paths = this.searchPaths; + for (var i = 0; i < paths.length; i++) { + var basePath = path.resolve(paths[i]); + var p = path.resolve(paths[i], name); + if (p.indexOf(basePath) === 0 && fs.existsSync(p)) { + fullpath = p; + break; + } + } + if (!fullpath) { + return null; + } + this.pathsToNames[fullpath] = name; + var source = { + src: fs.readFileSync(fullpath, "utf-8"), + path: fullpath, + noCache: this.noCache + }; + this.emit("load", name, source); + return source; + }; + return FileSystemLoader2; + }(Loader2); + var NodeResolveLoader = /* @__PURE__ */ function(_Loader2) { + _inheritsLoose(NodeResolveLoader2, _Loader2); + function NodeResolveLoader2(opts) { + var _this2; + _this2 = _Loader2.call(this) || this; + opts = opts || {}; + _this2.pathsToNames = {}; + _this2.noCache = !!opts.noCache; + if (opts.watch) { + try { + chokidar = require_chokidar(); + } catch (e2) { + throw new Error("watch requires chokidar to be installed"); + } + _this2.watcher = chokidar.watch(); + _this2.watcher.on("change", function(fullname) { + _this2.emit("update", _this2.pathsToNames[fullname], fullname); + }); + _this2.watcher.on("error", function(error) { + console.log("Watcher error: " + error); + }); + _this2.on("load", function(name, source) { + _this2.watcher.add(source.path); + }); + } + return _this2; + } + var _proto2 = NodeResolveLoader2.prototype; + _proto2.getSource = function getSource(name) { + if (/^\.?\.?(\/|\\)/.test(name)) { + return null; + } + if (/^[A-Z]:/.test(name)) { + return null; + } + var fullpath; + try { + fullpath = require.resolve(name); + } catch (e2) { + return null; + } + this.pathsToNames[fullpath] = name; + var source = { + src: fs.readFileSync(fullpath, "utf-8"), + path: fullpath, + noCache: this.noCache + }; + this.emit("load", name, source); + return source; + }; + return NodeResolveLoader2; + }(Loader2); + module2.exports = { + FileSystemLoader, + PrecompiledLoader, + NodeResolveLoader + }; +}); + +// ../../node_modules/nunjucks/src/loaders.js +var require_loaders = __commonJS((exports2, module2) => { + "use strict"; + module2.exports = require_node_loaders(); +}); + +// ../../node_modules/nunjucks/src/tests.js +var require_tests = __commonJS((exports2) => { + "use strict"; + var SafeString = require_runtime().SafeString; + function callable(value) { + return typeof value === "function"; + } + exports2.callable = callable; + function defined(value) { + return value !== void 0; + } + exports2.defined = defined; + function divisibleby(one, two) { + return one % two === 0; + } + exports2.divisibleby = divisibleby; + function escaped(value) { + return value instanceof SafeString; + } + exports2.escaped = escaped; + function equalto(one, two) { + return one === two; + } + exports2.equalto = equalto; + exports2.eq = exports2.equalto; + exports2.sameas = exports2.equalto; + function even(value) { + return value % 2 === 0; + } + exports2.even = even; + function falsy(value) { + return !value; + } + exports2.falsy = falsy; + function ge(one, two) { + return one >= two; + } + exports2.ge = ge; + function greaterthan(one, two) { + return one > two; + } + exports2.greaterthan = greaterthan; + exports2.gt = exports2.greaterthan; + function le(one, two) { + return one <= two; + } + exports2.le = le; + function lessthan(one, two) { + return one < two; + } + exports2.lessthan = lessthan; + exports2.lt = exports2.lessthan; + function lower(value) { + return value.toLowerCase() === value; + } + exports2.lower = lower; + function ne(one, two) { + return one !== two; + } + exports2.ne = ne; + function nullTest(value) { + return value === null; + } + exports2.null = nullTest; + function number(value) { + return typeof value === "number"; + } + exports2.number = number; + function odd(value) { + return value % 2 === 1; + } + exports2.odd = odd; + function string(value) { + return typeof value === "string"; + } + exports2.string = string; + function truthy(value) { + return !!value; + } + exports2.truthy = truthy; + function undefinedTest(value) { + return value === void 0; + } + exports2.undefined = undefinedTest; + function upper(value) { + return value.toUpperCase() === value; + } + exports2.upper = upper; + function iterable(value) { + if (typeof Symbol !== "undefined") { + return !!value[Symbol.iterator]; + } else { + return Array.isArray(value) || typeof value === "string"; + } + } + exports2.iterable = iterable; + function mapping(value) { + var bool = value !== null && value !== void 0 && typeof value === "object" && !Array.isArray(value); + if (Set) { + return bool && !(value instanceof Set); + } else { + return bool; + } + } + exports2.mapping = mapping; +}); + +// ../../node_modules/nunjucks/src/globals.js +var require_globals = __commonJS((exports2, module2) => { + "use strict"; + function _cycler(items) { + var index = -1; + return { + current: null, + reset: function reset2() { + index = -1; + this.current = null; + }, + next: function next() { + index++; + if (index >= items.length) { + index = 0; + } + this.current = items[index]; + return this.current; + } + }; + } + function _joiner(sep) { + sep = sep || ","; + var first = true; + return function() { + var val = first ? "" : sep; + first = false; + return val; + }; + } + function globals() { + return { + range: function range(start, stop, step) { + if (typeof stop === "undefined") { + stop = start; + start = 0; + step = 1; + } else if (!step) { + step = 1; + } + var arr = []; + if (step > 0) { + for (var i = start; i < stop; i += step) { + arr.push(i); + } + } else { + for (var _i = start; _i > stop; _i += step) { + arr.push(_i); + } + } + return arr; + }, + cycler: function cycler() { + return _cycler(Array.prototype.slice.call(arguments)); + }, + joiner: function joiner(sep) { + return _joiner(sep); + } + }; + } + module2.exports = globals; +}); + +// ../../node_modules/nunjucks/src/express-app.js +var require_express_app = __commonJS((exports2, module2) => { + "use strict"; + var path = require("path"); + module2.exports = function express(env, app) { + function NunjucksView(name, opts) { + this.name = name; + this.path = name; + this.defaultEngine = opts.defaultEngine; + this.ext = path.extname(name); + if (!this.ext && !this.defaultEngine) { + throw new Error("No default engine was specified and no extension was provided."); + } + if (!this.ext) { + this.name += this.ext = (this.defaultEngine[0] !== "." ? "." : "") + this.defaultEngine; + } + } + NunjucksView.prototype.render = function render2(opts, cb) { + env.render(this.name, opts, cb); + }; + app.set("view", NunjucksView); + app.set("nunjucksEnv", env); + return env; + }; +}); + +// ../../node_modules/nunjucks/src/environment.js +var require_environment = __commonJS((exports2, module2) => { + "use strict"; + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + _setPrototypeOf(subClass, superClass); + } + function _setPrototypeOf(o, p) { + _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf2(o2, p2) { + o2.__proto__ = p2; + return o2; + }; + return _setPrototypeOf(o, p); + } + var asap = require_asap(); + var _waterfall = require_a_sync_waterfall(); + var lib2 = require_lib(); + var compiler2 = require_compiler(); + var filters = require_filters(); + var _require2 = require_loaders(); + var FileSystemLoader = _require2.FileSystemLoader; + var WebLoader = _require2.WebLoader; + var PrecompiledLoader = _require2.PrecompiledLoader; + var tests = require_tests(); + var globals = require_globals(); + var _require22 = require_object(); + var Obj = _require22.Obj; + var EmitterObj = _require22.EmitterObj; + var globalRuntime = require_runtime(); + var handleError = globalRuntime.handleError; + var Frame = globalRuntime.Frame; + var expressApp = require_express_app(); + function callbackAsap(cb, err, res) { + asap(function() { + cb(err, res); + }); + } + var noopTmplSrc = { + type: "code", + obj: { + root: function root(env, context, frame, runtime2, cb) { + try { + cb(null, ""); + } catch (e2) { + cb(handleError(e2, null, null)); + } + } + } + }; + var Environment2 = /* @__PURE__ */ function(_EmitterObj) { + _inheritsLoose(Environment3, _EmitterObj); + function Environment3() { + return _EmitterObj.apply(this, arguments) || this; + } + var _proto = Environment3.prototype; + _proto.init = function init(loaders2, opts) { + var _this = this; + opts = this.opts = opts || {}; + this.opts.dev = !!opts.dev; + this.opts.autoescape = opts.autoescape != null ? opts.autoescape : true; + this.opts.throwOnUndefined = !!opts.throwOnUndefined; + this.opts.trimBlocks = !!opts.trimBlocks; + this.opts.lstripBlocks = !!opts.lstripBlocks; + this.loaders = []; + if (!loaders2) { + if (FileSystemLoader) { + this.loaders = [new FileSystemLoader("views")]; + } else if (WebLoader) { + this.loaders = [new WebLoader("/views")]; + } + } else { + this.loaders = lib2.isArray(loaders2) ? loaders2 : [loaders2]; + } + if (typeof window !== "undefined" && window.nunjucksPrecompiled) { + this.loaders.unshift(new PrecompiledLoader(window.nunjucksPrecompiled)); + } + this._initLoaders(); + this.globals = globals(); + this.filters = {}; + this.tests = {}; + this.asyncFilters = []; + this.extensions = {}; + this.extensionsList = []; + lib2._entries(filters).forEach(function(_ref) { + var name = _ref[0], filter = _ref[1]; + return _this.addFilter(name, filter); + }); + lib2._entries(tests).forEach(function(_ref2) { + var name = _ref2[0], test = _ref2[1]; + return _this.addTest(name, test); + }); + }; + _proto._initLoaders = function _initLoaders() { + var _this2 = this; + this.loaders.forEach(function(loader) { + loader.cache = {}; + if (typeof loader.on === "function") { + loader.on("update", function(name, fullname) { + loader.cache[name] = null; + _this2.emit("update", name, fullname, loader); + }); + loader.on("load", function(name, source) { + _this2.emit("load", name, source, loader); + }); + } + }); + }; + _proto.invalidateCache = function invalidateCache() { + this.loaders.forEach(function(loader) { + loader.cache = {}; + }); + }; + _proto.addExtension = function addExtension(name, extension) { + extension.__name = name; + this.extensions[name] = extension; + this.extensionsList.push(extension); + return this; + }; + _proto.removeExtension = function removeExtension(name) { + var extension = this.getExtension(name); + if (!extension) { + return; + } + this.extensionsList = lib2.without(this.extensionsList, extension); + delete this.extensions[name]; + }; + _proto.getExtension = function getExtension(name) { + return this.extensions[name]; + }; + _proto.hasExtension = function hasExtension(name) { + return !!this.extensions[name]; + }; + _proto.addGlobal = function addGlobal(name, value) { + this.globals[name] = value; + return this; + }; + _proto.getGlobal = function getGlobal(name) { + if (typeof this.globals[name] === "undefined") { + throw new Error("global not found: " + name); + } + return this.globals[name]; + }; + _proto.addFilter = function addFilter(name, func, async) { + var wrapped = func; + if (async) { + this.asyncFilters.push(name); + } + this.filters[name] = wrapped; + return this; + }; + _proto.getFilter = function getFilter(name) { + if (!this.filters[name]) { + throw new Error("filter not found: " + name); + } + return this.filters[name]; + }; + _proto.addTest = function addTest(name, func) { + this.tests[name] = func; + return this; + }; + _proto.getTest = function getTest(name) { + if (!this.tests[name]) { + throw new Error("test not found: " + name); + } + return this.tests[name]; + }; + _proto.resolveTemplate = function resolveTemplate(loader, parentName, filename) { + var isRelative = loader.isRelative && parentName ? loader.isRelative(filename) : false; + return isRelative && loader.resolve ? loader.resolve(parentName, filename) : filename; + }; + _proto.getTemplate = function getTemplate(name, eagerCompile, parentName, ignoreMissing, cb) { + var _this3 = this; + var that = this; + var tmpl = null; + if (name && name.raw) { + name = name.raw; + } + if (lib2.isFunction(parentName)) { + cb = parentName; + parentName = null; + eagerCompile = eagerCompile || false; + } + if (lib2.isFunction(eagerCompile)) { + cb = eagerCompile; + eagerCompile = false; + } + if (name instanceof Template2) { + tmpl = name; + } else if (typeof name !== "string") { + throw new Error("template names must be a string: " + name); + } else { + for (var i = 0; i < this.loaders.length; i++) { + var loader = this.loaders[i]; + tmpl = loader.cache[this.resolveTemplate(loader, parentName, name)]; + if (tmpl) { + break; + } + } + } + if (tmpl) { + if (eagerCompile) { + tmpl.compile(); + } + if (cb) { + cb(null, tmpl); + return void 0; + } else { + return tmpl; + } + } + var syncResult; + var createTemplate = function createTemplate2(err, info) { + if (!info && !err && !ignoreMissing) { + err = new Error("template not found: " + name); + } + if (err) { + if (cb) { + cb(err); + return; + } else { + throw err; + } + } + var newTmpl; + if (!info) { + newTmpl = new Template2(noopTmplSrc, _this3, "", eagerCompile); + } else { + newTmpl = new Template2(info.src, _this3, info.path, eagerCompile); + if (!info.noCache) { + info.loader.cache[name] = newTmpl; + } + } + if (cb) { + cb(null, newTmpl); + } else { + syncResult = newTmpl; + } + }; + lib2.asyncIter(this.loaders, function(loader2, i2, next, done) { + function handle(err, src) { + if (err) { + done(err); + } else if (src) { + src.loader = loader2; + done(null, src); + } else { + next(); + } + } + name = that.resolveTemplate(loader2, parentName, name); + if (loader2.async) { + loader2.getSource(name, handle); + } else { + handle(null, loader2.getSource(name)); + } + }, createTemplate); + return syncResult; + }; + _proto.express = function express(app) { + return expressApp(this, app); + }; + _proto.render = function render2(name, ctx, cb) { + if (lib2.isFunction(ctx)) { + cb = ctx; + ctx = null; + } + var syncResult = null; + this.getTemplate(name, function(err, tmpl) { + if (err && cb) { + callbackAsap(cb, err); + } else if (err) { + throw err; + } else { + syncResult = tmpl.render(ctx, cb); + } + }); + return syncResult; + }; + _proto.renderString = function renderString2(src, ctx, opts, cb) { + if (lib2.isFunction(opts)) { + cb = opts; + opts = {}; + } + opts = opts || {}; + var tmpl = new Template2(src, this, opts.path); + return tmpl.render(ctx, cb); + }; + _proto.waterfall = function waterfall(tasks, callback, forceAsync) { + return _waterfall(tasks, callback, forceAsync); + }; + return Environment3; + }(EmitterObj); + var Context = /* @__PURE__ */ function(_Obj) { + _inheritsLoose(Context2, _Obj); + function Context2() { + return _Obj.apply(this, arguments) || this; + } + var _proto2 = Context2.prototype; + _proto2.init = function init(ctx, blocks, env) { + var _this4 = this; + this.env = env || new Environment2(); + this.ctx = lib2.extend({}, ctx); + this.blocks = {}; + this.exported = []; + lib2.keys(blocks).forEach(function(name) { + _this4.addBlock(name, blocks[name]); + }); + }; + _proto2.lookup = function lookup(name) { + if (name in this.env.globals && !(name in this.ctx)) { + return this.env.globals[name]; + } else { + return this.ctx[name]; + } + }; + _proto2.setVariable = function setVariable(name, val) { + this.ctx[name] = val; + }; + _proto2.getVariables = function getVariables() { + return this.ctx; + }; + _proto2.addBlock = function addBlock(name, block) { + this.blocks[name] = this.blocks[name] || []; + this.blocks[name].push(block); + return this; + }; + _proto2.getBlock = function getBlock(name) { + if (!this.blocks[name]) { + throw new Error('unknown block "' + name + '"'); + } + return this.blocks[name][0]; + }; + _proto2.getSuper = function getSuper(env, name, block, frame, runtime2, cb) { + var idx = lib2.indexOf(this.blocks[name] || [], block); + var blk = this.blocks[name][idx + 1]; + var context = this; + if (idx === -1 || !blk) { + throw new Error('no super block available for "' + name + '"'); + } + blk(env, context, frame, runtime2, cb); + }; + _proto2.addExport = function addExport(name) { + this.exported.push(name); + }; + _proto2.getExported = function getExported() { + var _this5 = this; + var exported = {}; + this.exported.forEach(function(name) { + exported[name] = _this5.ctx[name]; + }); + return exported; + }; + return Context2; + }(Obj); + var Template2 = /* @__PURE__ */ function(_Obj2) { + _inheritsLoose(Template3, _Obj2); + function Template3() { + return _Obj2.apply(this, arguments) || this; + } + var _proto3 = Template3.prototype; + _proto3.init = function init(src, env, path, eagerCompile) { + this.env = env || new Environment2(); + if (lib2.isObject(src)) { + switch (src.type) { + case "code": + this.tmplProps = src.obj; + break; + case "string": + this.tmplStr = src.obj; + break; + default: + throw new Error("Unexpected template object type " + src.type + "; expected 'code', or 'string'"); + } + } else if (lib2.isString(src)) { + this.tmplStr = src; + } else { + throw new Error("src must be a string or an object describing the source"); + } + this.path = path; + if (eagerCompile) { + try { + this._compile(); + } catch (err) { + throw lib2._prettifyError(this.path, this.env.opts.dev, err); + } + } else { + this.compiled = false; + } + }; + _proto3.render = function render2(ctx, parentFrame, cb) { + var _this6 = this; + if (typeof ctx === "function") { + cb = ctx; + ctx = {}; + } else if (typeof parentFrame === "function") { + cb = parentFrame; + parentFrame = null; + } + var forceAsync = !parentFrame; + try { + this.compile(); + } catch (e2) { + var err = lib2._prettifyError(this.path, this.env.opts.dev, e2); + if (cb) { + return callbackAsap(cb, err); + } else { + throw err; + } + } + var context = new Context(ctx || {}, this.blocks, this.env); + var frame = parentFrame ? parentFrame.push(true) : new Frame(); + frame.topLevel = true; + var syncResult = null; + var didError = false; + this.rootRenderFunc(this.env, context, frame, globalRuntime, function(err2, res) { + if (didError && cb && typeof res !== "undefined") { + return; + } + if (err2) { + err2 = lib2._prettifyError(_this6.path, _this6.env.opts.dev, err2); + didError = true; + } + if (cb) { + if (forceAsync) { + callbackAsap(cb, err2, res); + } else { + cb(err2, res); + } + } else { + if (err2) { + throw err2; + } + syncResult = res; + } + }); + return syncResult; + }; + _proto3.getExported = function getExported(ctx, parentFrame, cb) { + if (typeof ctx === "function") { + cb = ctx; + ctx = {}; + } + if (typeof parentFrame === "function") { + cb = parentFrame; + parentFrame = null; + } + try { + this.compile(); + } catch (e2) { + if (cb) { + return cb(e2); + } else { + throw e2; + } + } + var frame = parentFrame ? parentFrame.push() : new Frame(); + frame.topLevel = true; + var context = new Context(ctx || {}, this.blocks, this.env); + this.rootRenderFunc(this.env, context, frame, globalRuntime, function(err) { + if (err) { + cb(err, null); + } else { + cb(null, context.getExported()); + } + }); + }; + _proto3.compile = function compile2() { + if (!this.compiled) { + this._compile(); + } + }; + _proto3._compile = function _compile() { + var props; + if (this.tmplProps) { + props = this.tmplProps; + } else { + var source = compiler2.compile(this.tmplStr, this.env.asyncFilters, this.env.extensionsList, this.path, this.env.opts); + var func = new Function(source); + props = func(); + } + this.blocks = this._getBlocks(props); + this.rootRenderFunc = props.root; + this.compiled = true; + }; + _proto3._getBlocks = function _getBlocks(props) { + var blocks = {}; + lib2.keys(props).forEach(function(k) { + if (k.slice(0, 2) === "b_") { + blocks[k.slice(2)] = props[k]; + } + }); + return blocks; + }; + return Template3; + }(Obj); + module2.exports = { + Environment: Environment2, + Template: Template2 + }; +}); + +// ../../node_modules/nunjucks/src/precompile-global.js +var require_precompile_global = __commonJS((exports2, module2) => { + "use strict"; + function precompileGlobal(templates, opts) { + var out = ""; + opts = opts || {}; + for (var i = 0; i < templates.length; i++) { + var name = JSON.stringify(templates[i].name); + var template = templates[i].template; + out += "(function() {(window.nunjucksPrecompiled = window.nunjucksPrecompiled || {})[" + name + "] = (function() {\n" + template + "\n})();\n"; + if (opts.asFunction) { + out += "return function(ctx, cb) { return nunjucks.render(" + name + ", ctx, cb); }\n"; + } + out += "})();\n"; + } + return out; + } + module2.exports = precompileGlobal; +}); + +// ../../node_modules/nunjucks/src/precompile.js +var require_precompile = __commonJS((exports2, module2) => { + "use strict"; + var fs = require("fs"); + var path = require("path"); + var _require2 = require_lib(); + var _prettifyError = _require2._prettifyError; + var compiler2 = require_compiler(); + var _require22 = require_environment(); + var Environment2 = _require22.Environment; + var precompileGlobal = require_precompile_global(); + function match(filename, patterns) { + if (!Array.isArray(patterns)) { + return false; + } + return patterns.some(function(pattern) { + return filename.match(pattern); + }); + } + function precompileString(str, opts) { + opts = opts || {}; + opts.isString = true; + var env = opts.env || new Environment2([]); + var wrapper = opts.wrapper || precompileGlobal; + if (!opts.name) { + throw new Error('the "name" option is required when compiling a string'); + } + return wrapper([_precompile(str, opts.name, env)], opts); + } + function precompile2(input, opts) { + opts = opts || {}; + var env = opts.env || new Environment2([]); + var wrapper = opts.wrapper || precompileGlobal; + if (opts.isString) { + return precompileString(input, opts); + } + var pathStats = fs.existsSync(input) && fs.statSync(input); + var precompiled = []; + var templates = []; + function addTemplates(dir) { + fs.readdirSync(dir).forEach(function(file) { + var filepath = path.join(dir, file); + var subpath = filepath.substr(path.join(input, "/").length); + var stat = fs.statSync(filepath); + if (stat && stat.isDirectory()) { + subpath += "/"; + if (!match(subpath, opts.exclude)) { + addTemplates(filepath); + } + } else if (match(subpath, opts.include)) { + templates.push(filepath); + } + }); + } + if (pathStats.isFile()) { + precompiled.push(_precompile(fs.readFileSync(input, "utf-8"), opts.name || input, env)); + } else if (pathStats.isDirectory()) { + addTemplates(input); + for (var i = 0; i < templates.length; i++) { + var name = templates[i].replace(path.join(input, "/"), ""); + try { + precompiled.push(_precompile(fs.readFileSync(templates[i], "utf-8"), name, env)); + } catch (e2) { + if (opts.force) { + console.error(e2); + } else { + throw e2; + } + } + } + } + return wrapper(precompiled, opts); + } + function _precompile(str, name, env) { + env = env || new Environment2([]); + var asyncFilters = env.asyncFilters; + var extensions = env.extensionsList; + var template; + name = name.replace(/\\/g, "/"); + try { + template = compiler2.compile(str, asyncFilters, extensions, name, env.opts); + } catch (err) { + throw _prettifyError(name, false, err); + } + return { + name, + template + }; + } + module2.exports = { + precompile: precompile2, + precompileString + }; +}); + +// ../../node_modules/nunjucks/src/jinja-compat.js +var require_jinja_compat = __commonJS((exports2, module2) => { + "use strict"; + function installCompat() { + "use strict"; + var runtime2 = this.runtime; + var lib2 = this.lib; + var Compiler = this.compiler.Compiler; + var Parser = this.parser.Parser; + var nodes2 = this.nodes; + var lexer2 = this.lexer; + var orig_contextOrFrameLookup = runtime2.contextOrFrameLookup; + var orig_memberLookup = runtime2.memberLookup; + var orig_Compiler_assertType; + var orig_Parser_parseAggregate; + if (Compiler) { + orig_Compiler_assertType = Compiler.prototype.assertType; + } + if (Parser) { + orig_Parser_parseAggregate = Parser.prototype.parseAggregate; + } + function uninstall() { + runtime2.contextOrFrameLookup = orig_contextOrFrameLookup; + runtime2.memberLookup = orig_memberLookup; + if (Compiler) { + Compiler.prototype.assertType = orig_Compiler_assertType; + } + if (Parser) { + Parser.prototype.parseAggregate = orig_Parser_parseAggregate; + } + } + runtime2.contextOrFrameLookup = function contextOrFrameLookup(context, frame, key) { + var val = orig_contextOrFrameLookup.apply(this, arguments); + if (val !== void 0) { + return val; + } + switch (key) { + case "True": + return true; + case "False": + return false; + case "None": + return null; + default: + return void 0; + } + }; + function getTokensState(tokens) { + return { + index: tokens.index, + lineno: tokens.lineno, + colno: tokens.colno + }; + } + if (process.env.BUILD_TYPE !== "SLIM" && nodes2 && Compiler && Parser) { + var Slice = nodes2.Node.extend("Slice", { + fields: ["start", "stop", "step"], + init: function init(lineno, colno, start, stop, step) { + start = start || new nodes2.Literal(lineno, colno, null); + stop = stop || new nodes2.Literal(lineno, colno, null); + step = step || new nodes2.Literal(lineno, colno, 1); + this.parent(lineno, colno, start, stop, step); + } + }); + Compiler.prototype.assertType = function assertType(node) { + if (node instanceof Slice) { + return; + } + orig_Compiler_assertType.apply(this, arguments); + }; + Compiler.prototype.compileSlice = function compileSlice(node, frame) { + this._emit("("); + this._compileExpression(node.start, frame); + this._emit("),("); + this._compileExpression(node.stop, frame); + this._emit("),("); + this._compileExpression(node.step, frame); + this._emit(")"); + }; + Parser.prototype.parseAggregate = function parseAggregate() { + var _this = this; + var origState = getTokensState(this.tokens); + origState.colno--; + origState.index--; + try { + return orig_Parser_parseAggregate.apply(this); + } catch (e2) { + var errState = getTokensState(this.tokens); + var rethrow = function rethrow2() { + lib2._assign(_this.tokens, errState); + return e2; + }; + lib2._assign(this.tokens, origState); + this.peeked = false; + var tok = this.peekToken(); + if (tok.type !== lexer2.TOKEN_LEFT_BRACKET) { + throw rethrow(); + } else { + this.nextToken(); + } + var node = new Slice(tok.lineno, tok.colno); + var isSlice = false; + for (var i = 0; i <= node.fields.length; i++) { + if (this.skip(lexer2.TOKEN_RIGHT_BRACKET)) { + break; + } + if (i === node.fields.length) { + if (isSlice) { + this.fail("parseSlice: too many slice components", tok.lineno, tok.colno); + } else { + break; + } + } + if (this.skip(lexer2.TOKEN_COLON)) { + isSlice = true; + } else { + var field = node.fields[i]; + node[field] = this.parseExpression(); + isSlice = this.skip(lexer2.TOKEN_COLON) || isSlice; + } + } + if (!isSlice) { + throw rethrow(); + } + return new nodes2.Array(tok.lineno, tok.colno, [node]); + } + }; + } + function sliceLookup(obj, start, stop, step) { + obj = obj || []; + if (start === null) { + start = step < 0 ? obj.length - 1 : 0; + } + if (stop === null) { + stop = step < 0 ? -1 : obj.length; + } else if (stop < 0) { + stop += obj.length; + } + if (start < 0) { + start += obj.length; + } + var results = []; + for (var i = start; ; i += step) { + if (i < 0 || i > obj.length) { + break; + } + if (step > 0 && i >= stop) { + break; + } + if (step < 0 && i <= stop) { + break; + } + results.push(runtime2.memberLookup(obj, i)); + } + return results; + } + function hasOwnProp(obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); + } + var ARRAY_MEMBERS = { + pop: function pop(index) { + if (index === void 0) { + return this.pop(); + } + if (index >= this.length || index < 0) { + throw new Error("KeyError"); + } + return this.splice(index, 1); + }, + append: function append(element) { + return this.push(element); + }, + remove: function remove(element) { + for (var i = 0; i < this.length; i++) { + if (this[i] === element) { + return this.splice(i, 1); + } + } + throw new Error("ValueError"); + }, + count: function count(element) { + var count2 = 0; + for (var i = 0; i < this.length; i++) { + if (this[i] === element) { + count2++; + } + } + return count2; + }, + index: function index(element) { + var i; + if ((i = this.indexOf(element)) === -1) { + throw new Error("ValueError"); + } + return i; + }, + find: function find(element) { + return this.indexOf(element); + }, + insert: function insert(index, elem) { + return this.splice(index, 0, elem); + } + }; + var OBJECT_MEMBERS = { + items: function items() { + return lib2._entries(this); + }, + values: function values() { + return lib2._values(this); + }, + keys: function keys() { + return lib2.keys(this); + }, + get: function get(key, def) { + var output = this[key]; + if (output === void 0) { + output = def; + } + return output; + }, + has_key: function has_key(key) { + return hasOwnProp(this, key); + }, + pop: function pop(key, def) { + var output = this[key]; + if (output === void 0 && def !== void 0) { + output = def; + } else if (output === void 0) { + throw new Error("KeyError"); + } else { + delete this[key]; + } + return output; + }, + popitem: function popitem() { + var keys = lib2.keys(this); + if (!keys.length) { + throw new Error("KeyError"); + } + var k = keys[0]; + var val = this[k]; + delete this[k]; + return [k, val]; + }, + setdefault: function setdefault(key, def) { + if (def === void 0) { + def = null; + } + if (!(key in this)) { + this[key] = def; + } + return this[key]; + }, + update: function update(kwargs) { + lib2._assign(this, kwargs); + return null; + } + }; + OBJECT_MEMBERS.iteritems = OBJECT_MEMBERS.items; + OBJECT_MEMBERS.itervalues = OBJECT_MEMBERS.values; + OBJECT_MEMBERS.iterkeys = OBJECT_MEMBERS.keys; + runtime2.memberLookup = function memberLookup(obj, val, autoescape) { + if (arguments.length === 4) { + return sliceLookup.apply(this, arguments); + } + obj = obj || {}; + if (lib2.isArray(obj) && hasOwnProp(ARRAY_MEMBERS, val)) { + return ARRAY_MEMBERS[val].bind(obj); + } + if (lib2.isObject(obj) && hasOwnProp(OBJECT_MEMBERS, val)) { + return OBJECT_MEMBERS[val].bind(obj); + } + return orig_memberLookup.apply(this, arguments); + }; + return uninstall; + } + module2.exports = installCompat; +}); + +// ../../node_modules/nunjucks/index.js +"use strict"; +var lib = require_lib(); +var _require = require_environment(); +var Environment = _require.Environment; +var Template = _require.Template; +var Loader = require_loader(); +var loaders = require_loaders(); +var precompile = require_precompile(); +var compiler = require_compiler(); +var parser = require_parser(); +var lexer = require_lexer(); +var runtime = require_runtime(); +var nodes = require_nodes(); +var installJinjaCompat = require_jinja_compat(); +var e; +function configure(templatesPath, opts) { + opts = opts || {}; + if (lib.isObject(templatesPath)) { + opts = templatesPath; + templatesPath = null; + } + var TemplateLoader; + if (loaders.FileSystemLoader) { + TemplateLoader = new loaders.FileSystemLoader(templatesPath, { + watch: opts.watch, + noCache: opts.noCache + }); + } else if (loaders.WebLoader) { + TemplateLoader = new loaders.WebLoader(templatesPath, { + useCache: opts.web && opts.web.useCache, + async: opts.web && opts.web.async + }); + } + e = new Environment(TemplateLoader, opts); + if (opts && opts.express) { + e.express(opts.express); + } + return e; +} +module.exports = { + Environment, + Template, + Loader, + FileSystemLoader: loaders.FileSystemLoader, + NodeResolveLoader: loaders.NodeResolveLoader, + PrecompiledLoader: loaders.PrecompiledLoader, + WebLoader: loaders.WebLoader, + compiler, + parser, + lexer, + runtime, + lib, + nodes, + installJinjaCompat, + configure, + reset: function reset() { + e = void 0; + }, + compile: function compile(src, env, path, eagerCompile) { + if (!e) { + configure(); + } + return new Template(src, env, path, eagerCompile); + }, + render: function render(name, ctx, cb) { + if (!e) { + configure(); + } + return e.render(name, ctx, cb); + }, + renderString: function renderString(src, ctx, cb) { + if (!e) { + configure(); + } + return e.renderString(src, ctx, cb); + }, + precompile: precompile ? precompile.precompile : void 0, + precompileString: precompile ? precompile.precompileString : void 0 +}; diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts index 126c55e166..aa4ecf8053 100644 --- a/plugins/scaffolder-backend/config.d.ts +++ b/plugins/scaffolder-backend/config.d.ts @@ -28,30 +28,30 @@ export interface Config { * The commit message used when new components are created. */ defaultCommitMessage?: string; + /** + * @deprecated Replaced by parameters for the publish:github action + */ github?: { [key: string]: string; - /** - * The visibility to set on created repositories. - */ - visibility?: 'public' | 'internal' | 'private'; }; + /** + * @deprecated Use the Gitlab integration instead + */ gitlab?: { api: { [key: string]: string }; - /** - * The visibility to set on created repositories. - */ - visibility?: 'public' | 'internal' | 'private'; }; + /** + * @deprecated Use the Azure integration instead + */ azure?: { baseUrl: string; api: { [key: string]: string }; }; + /** + * @deprecated Use the Bitbucket integration instead + */ bitbucket?: { api: { [key: string]: string }; - /** - * The visibility to set on created repositories. - */ - visibility?: 'public' | 'private'; }; }; } diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index e5d13801ba..bbe59d6458 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-scaffolder-backend", "description": "The Backstage backend plugin that helps you create new things", - "version": "0.15.12", + "version": "0.15.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,21 +27,22 @@ "test": "backstage-cli test", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" + "clean": "backstage-cli clean", + "build:assets": "node scripts/build-nunjucks.js" }, "dependencies": { - "@backstage/backend-common": "^0.9.9", - "@backstage/catalog-client": "^0.5.1", - "@backstage/catalog-model": "^0.9.6", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", - "@backstage/plugin-catalog-backend": "^0.17.3", + "@backstage/errors": "^0.1.5", + "@backstage/integration": "^0.6.10", + "@backstage/plugin-catalog-backend": "^0.19.0", "@backstage/plugin-scaffolder-common": "^0.1.1", - "@backstage/plugin-scaffolder-backend-module-cookiecutter": "^0.1.4", + "@backstage/plugin-scaffolder-backend-module-cookiecutter": "^0.1.5", "@backstage/types": "^0.1.1", - "@gitbeaker/core": "^30.2.0", - "@gitbeaker/node": "^30.2.0", + "@gitbeaker/core": "^34.6.0", + "@gitbeaker/node": "^34.6.0", "@octokit/rest": "^18.5.3", "@octokit/webhooks": "^9.14.1", "@types/express": "^4.17.6", @@ -49,7 +50,6 @@ "command-exists": "^1.2.9", "compression": "^1.7.4", "cors": "^2.8.5", - "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", "fs-extra": "10.0.0", @@ -64,21 +64,24 @@ "lodash": "^4.17.21", "luxon": "^2.0.2", "morgan": "^1.10.0", + "node-fetch": "^2.6.1", "nunjucks": "^3.2.3", "octokit-plugin-create-pull-request": "^3.10.0", "uuid": "^8.2.0", "winston": "^3.2.1", - "yaml": "^1.10.0" + "yaml": "^1.10.0", + "vm2": "^3.9.5" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/test-utils": "^0.1.23", "@types/command-exists": "^1.2.0", "@types/fs-extra": "^9.0.1", "@types/git-url-parse": "^9.0.0", "@types/mock-fs": "^4.13.0", "@types/nunjucks": "^3.1.4", "@types/supertest": "^2.0.8", + "esbuild": "^0.14.1", "jest-when": "^3.1.0", "mock-fs": "^5.1.0", "msw": "^0.35.0", @@ -88,7 +91,8 @@ "files": [ "dist", "migrations", - "config.d.ts" + "config.d.ts", + "assets" ], "configSchema": "config.d.ts" } diff --git a/plugins/scaffolder-backend/scripts/build-nunjucks.js b/plugins/scaffolder-backend/scripts/build-nunjucks.js new file mode 100755 index 0000000000..b195f96159 --- /dev/null +++ b/plugins/scaffolder-backend/scripts/build-nunjucks.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable no-restricted-syntax */ +/* eslint-disable import/no-extraneous-dependencies */ + +const path = require('path'); + +const NUNJUCKS_LICENSE = `/** + * Copyright (c) 2012-2015, James Long + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +`; + +// This script is used to bundle nunjucks into a single script that is +// loaded into a sandbox for executing templates. +require('esbuild') + .build({ + entryPoints: [require.resolve('nunjucks')], + bundle: true, + format: 'cjs', + platform: 'node', + target: 'node14', + banner: { js: NUNJUCKS_LICENSE }, + external: ['fsevents'], + outfile: path.resolve(__dirname, '../assets/nunjucks.js.txt'), + }) + .catch(err => { + console.log(err.stack); + process.exit(1); + }); diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts new file mode 100644 index 0000000000..134b877efe --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SecureTemplater } from './SecureTemplater'; + +describe('SecureTemplater', () => { + it('should render some templates', async () => { + const render = await SecureTemplater.loadRenderer(); + expect(render('${{ test }}', { test: 'my-value' })).toBe('my-value'); + + expect(render('${{ test | dump }}', { test: 'my-value' })).toBe( + '"my-value"', + ); + + expect( + render('${{ test | replace("my-", "our-") }}', { + test: 'my-value', + }), + ).toBe('our-value'); + + expect(() => + render('${{ invalid...syntax }}', { + test: 'my-value', + }), + ).toThrow(/expected name as lookup value, got ./); + }); + + it('should make cookiecutter compatibility available when requested', async () => { + const renderWith = await SecureTemplater.loadRenderer({ + cookiecutterCompat: true, + }); + const renderWithout = await SecureTemplater.loadRenderer(); + + // Same two tests repeated to make sure switching back and forth works + expect(renderWith('{{ 1 | jsonify }}', {})).toBe('1'); + expect(renderWith('{{ 1 | jsonify }}', {})).toBe('1'); + expect(() => renderWithout('${{ 1 | jsonify }}', {})).toThrow( + /Error: filter not found: jsonify/, + ); + expect(renderWith('{{ 1 | jsonify }}', {})).toBe('1'); + expect(() => renderWithout('${{ 1 | jsonify }}', {})).toThrow( + /Error: filter not found: jsonify/, + ); + expect(() => renderWithout('${{ 1 | jsonify }}', {})).toThrow( + /Error: filter not found: jsonify/, + ); + expect(() => renderWithout('${{ 1 | jsonify }}', {})).toThrow( + /Error: filter not found: jsonify/, + ); + expect(renderWith('{{ 1 | jsonify }}', {})).toBe('1'); + }); + + it('should make parseRepoUrl available when requested', async () => { + const parseRepoUrl = jest.fn(() => ({ + repo: 'my-repo', + owner: 'my-owner', + host: 'my-host.com', + })); + const renderWith = await SecureTemplater.loadRenderer({ parseRepoUrl }); + const renderWithout = await SecureTemplater.loadRenderer(); + + const ctx = { + repoUrl: 'https://my-host.com/my-owner/my-repo', + }; + + expect(renderWith('${{ repoUrl | parseRepoUrl | dump }}', ctx)).toBe( + JSON.stringify({ + repo: 'my-repo', + owner: 'my-owner', + host: 'my-host.com', + }), + ); + expect(renderWith('${{ repoUrl | projectSlug }}', ctx)).toBe( + 'my-owner/my-repo', + ); + expect(() => + renderWithout('${{ repoUrl | parseRepoUrl | dump }}', ctx), + ).toThrow(/Error: filter not found: parseRepoUrl/); + expect(() => renderWithout('${{ repoUrl | projectSlug }}', ctx)).toThrow( + /Error: filter not found: projectSlug/, + ); + + expect(parseRepoUrl.mock.calls).toEqual([ + ['https://my-host.com/my-owner/my-repo'], + ['https://my-host.com/my-owner/my-repo'], + ]); + }); + + it('should not allow helpers to be rewritten', async () => { + const render = await SecureTemplater.loadRenderer({ + parseRepoUrl: () => ({ + repo: 'my-repo', + owner: 'my-owner', + host: 'my-host.com', + }), + }); + + const ctx = { + repoUrl: 'https://my-host.com/my-owner/my-repo', + }; + expect( + render( + '${{ ({}).constructor.constructor("parseRepoUrl = () => JSON.stringify(`inject`)")() }}', + ctx, + ), + ).toBe(''); + + expect(render('${{ repoUrl | parseRepoUrl | dump }}', ctx)).toBe( + JSON.stringify({ + repo: 'my-repo', + owner: 'my-owner', + host: 'my-host.com', + }), + ); + }); + + it('allows pollution during a single template execution', async () => { + const render = await SecureTemplater.loadRenderer(); + + const ctx = { + x: 'foo', + }; + expect(render('${{ x }}', ctx)).toBe('foo'); + expect( + render( + '${{ ({}).constructor.constructor("Array.prototype.forEach = () => {}")() }}', + ctx, + ), + ).toBe(''); + expect(() => render('${{ x }}', ctx)).toThrow(); + }); +}); diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts new file mode 100644 index 0000000000..54c9166020 --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts @@ -0,0 +1,141 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { VM } from 'vm2'; +import { resolvePackagePath } from '@backstage/backend-common'; +import fs from 'fs-extra'; +import { RepoSpec } from '../../scaffolder/actions/builtin/publish/util'; + +const mkScript = (nunjucksSource: string) => ` +const { render, renderCompat } = (() => { + const module = {}; + const process = { env: {} }; + const require = (pkg) => { if (pkg === 'events') { return function (){}; }}; + + ${nunjucksSource} + + const env = module.exports.configure({ + autoescape: false, + tags: { + variableStart: '\${{', + variableEnd: '}}', + }, + }); + + const compatEnv = module.exports.configure({ + autoescape: false, + tags: { + variableStart: '{{', + variableEnd: '}}', + }, + }); + compatEnv.addFilter('jsonify', compatEnv.getFilter('dump')); + + if (typeof parseRepoUrl !== 'undefined') { + const safeHelperRef = parseRepoUrl; + + env.addFilter('parseRepoUrl', repoUrl => { + return JSON.parse(safeHelperRef(repoUrl)) + }); + env.addFilter('projectSlug', repoUrl => { + const { owner, repo } = JSON.parse(safeHelperRef(repoUrl)); + return owner + '/' + repo; + }); + } + + let uninstallCompat = undefined; + + function render(str, values) { + try { + if (uninstallCompat) { + uninstallCompat(); + uninstallCompat = undefined; + } + return env.renderString(str, JSON.parse(values)); + } catch (error) { + // Make sure errors don't leak anything + throw new Error(String(error.message)); + } + } + + function renderCompat(str, values) { + try { + if (!uninstallCompat) { + uninstallCompat = module.exports.installJinjaCompat(); + } + return compatEnv.renderString(str, JSON.parse(values)); + } catch (error) { + // Make sure errors don't leak anything + throw new Error(String(error.message)); + } + } + + return { render, renderCompat }; +})(); +`; + +export interface SecureTemplaterOptions { + /* Optional implementation of the parseRepoUrl filter */ + parseRepoUrl?(repoUrl: string): RepoSpec; + + /* Enables jinja compatibility and the "jsonify" filter */ + cookiecutterCompat?: boolean; +} + +export type SecureTemplateRenderer = ( + template: string, + values: unknown, +) => string; + +export class SecureTemplater { + static async loadRenderer(options: SecureTemplaterOptions = {}) { + const { parseRepoUrl, cookiecutterCompat } = options; + let sandbox = undefined; + + if (parseRepoUrl) { + sandbox = { + parseRepoUrl: (url: string) => JSON.stringify(parseRepoUrl(url)), + }; + } + + const vm = new VM({ sandbox }); + + const nunjucksSource = await fs.readFile( + resolvePackagePath( + '@backstage/plugin-scaffolder-backend', + 'assets/nunjucks.js.txt', + ), + 'utf-8', + ); + + vm.run(mkScript(nunjucksSource)); + + const render: SecureTemplateRenderer = (template, values) => { + if (!vm) { + throw new Error('SecureTemplater has not been initialized'); + } + vm.setGlobal('templateStr', template); + vm.setGlobal('templateValues', JSON.stringify(values)); + + if (cookiecutterCompat) { + return vm.run(`renderCompat(templateStr, templateValues)`); + } + + return vm.run(`render(templateStr, templateValues)`); + }; + return render; + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts index 45481b47e6..b813244020 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts @@ -15,10 +15,10 @@ */ import fs from 'fs-extra'; -import { resolve as resolvePath } from 'path'; import { createTemplateAction } from '../../createTemplateAction'; import * as yaml from 'yaml'; import { Entity } from '@backstage/catalog-model'; +import { resolveSafeChildPath } from '@backstage/backend-common'; export function createCatalogWriteAction() { return createTemplateAction<{ name?: string; entity: Entity }>({ @@ -42,7 +42,7 @@ export function createCatalogWriteAction() { const { entity } = ctx.input; await fs.writeFile( - resolvePath(ctx.workspacePath, 'catalog-info.yaml'), + resolveSafeChildPath(ctx.workspacePath, 'catalog-info.yaml'), yaml.stringify(entity), ); }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts index 9bf9b7fb90..36ea842a66 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts @@ -15,7 +15,7 @@ */ import { readdir, stat } from 'fs-extra'; -import { relative, resolve } from 'path'; +import { relative, join } from 'path'; import { createTemplateAction } from '../../createTemplateAction'; /** @@ -68,7 +68,7 @@ export async function recursiveReadDir(dir: string): Promise { const subdirs = await readdir(dir); const files = await Promise.all( subdirs.map(async subdir => { - const res = resolve(dir, subdir); + const res = join(dir, subdir); return (await stat(res)).isDirectory() ? recursiveReadDir(res) : [res]; }), ); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.ts index 3946110d42..b56bab0d53 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.ts @@ -19,7 +19,7 @@ import { JsonValue } from '@backstage/types'; import { InputError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import fs from 'fs-extra'; -import * as path from 'path'; +import path from 'path'; export async function fetchContents({ reader, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 755b435149..cf1662307c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -33,6 +33,17 @@ jest.mock('./helpers', () => ({ fetchContents: jest.fn(), })); +const realFiles = Object.fromEntries( + [ + require.resolve('vm2/lib/fixasync'), + resolvePackagePath( + '@backstage/plugin-scaffolder-backend', + 'assets', + 'nunjucks.js.txt', + ), + ].map(k => [k, mockFs.load(k)]), +); + const aBinaryFile = fs.readFileSync( resolvePackagePath( '@backstage/plugin-scaffolder-backend', @@ -76,7 +87,9 @@ describe('fetch:template', () => { }); beforeEach(() => { - mockFs(); + mockFs({ + ...realFiles, + }); action = createFetchTemplateAction({ reader: Symbol('UrlReader') as unknown as UrlReader, @@ -150,6 +163,7 @@ describe('fetch:template', () => { mockFetchContents.mockImplementation(({ outputPath }) => { mockFs({ + ...realFiles, [outputPath]: { 'an-executable.sh': mockFs.file({ content: '#!/usr/bin/env bash', @@ -259,6 +273,7 @@ describe('fetch:template', () => { mockFetchContents.mockImplementation(({ outputPath }) => { mockFs({ + ...realFiles, [outputPath]: { processed: { 'templated-content-${{ values.name }}.txt': @@ -312,6 +327,7 @@ describe('fetch:template', () => { mockFetchContents.mockImplementation(({ outputPath }) => { mockFs({ + ...realFiles, [outputPath]: { '{{ cookiecutter.name }}.txt': 'static content', subdir: { @@ -366,6 +382,7 @@ describe('fetch:template', () => { mockFetchContents.mockImplementation(({ outputPath }) => { mockFs({ + ...realFiles, [outputPath]: { 'empty-dir-${{ values.count }}': {}, 'static.txt': 'static content', @@ -447,6 +464,7 @@ describe('fetch:template', () => { mockFetchContents.mockImplementation(({ outputPath }) => { mockFs({ + ...realFiles, [outputPath]: { '${{ values.name }}.njk': '${{ values.name }}: ${{ values.count }}', '${{ values.name }}.txt.jinja2': diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index 47bfcccfdf..2e25547e61 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -14,29 +14,16 @@ * limitations under the License. */ -import { resolve as resolvePath, extname } from 'path'; +import { extname } from 'path'; import { resolveSafeChildPath, UrlReader } from '@backstage/backend-common'; import { InputError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import { fetchContents } from './helpers'; import { createTemplateAction } from '../../createTemplateAction'; import globby from 'globby'; -import nunjucks from 'nunjucks'; import fs from 'fs-extra'; import { isBinaryFile } from 'isbinaryfile'; - -/* - * Maximise compatibility with Jinja (and therefore cookiecutter) - * using nunjucks jinja compat mode. Since this method mutates - * the global nunjucks instance, we can't enable this per-template, - * or only for templates with cookiecutter compat enabled, so the - * next best option is to explicitly enable it globally and allow - * folks to rely on jinja compatibility behaviour in fetch:template - * templates if they wish. - * - * cf. https://mozilla.github.io/nunjucks/api.html#installjinjacompat - */ -nunjucks.installJinjaCompat(); +import { SecureTemplater } from '../../../../lib/templating/SecureTemplater'; type CookieCompatInput = { copyWithoutRender?: string[]; @@ -114,7 +101,7 @@ export function createFetchTemplateAction(options: { ctx.logger.info('Fetching template content from remote URL'); const workDir = await ctx.createTemporaryDirectory(); - const templateDir = resolvePath(workDir, 'template'); + const templateDir = resolveSafeChildPath(workDir, 'template'); const targetPath = ctx.input.targetPath ?? './'; const outputDir = resolveSafeChildPath(ctx.workspacePath, targetPath); @@ -179,36 +166,6 @@ export function createFetchTemplateAction(options: { ).flat(), ); - // Create a templater - const templater = nunjucks.configure({ - ...(ctx.input.cookiecutterCompat - ? {} - : { - tags: { - // TODO(mtlewis/orkohunter): Document Why we are changing the literals? Not here, but on scaffolder docs. ADR? - variableStart: '${{', - variableEnd: '}}', - }, - }), - // We don't want this builtin auto-escaping, since uses HTML escape sequences - // like `"` - the correct way to escape strings in our case depends on - // the file type. - autoescape: false, - }); - - if (ctx.input.cookiecutterCompat) { - // The "jsonify" filter built into cookiecutter is common - // in fetch:cookiecutter templates, so when compat mode - // is enabled we alias the "dump" filter from nunjucks as - // jsonify. Dump accepts an optional `spaces` parameter - // which enables indented output, but when this parameter - // is not supplied it works identically to jsonify. - // - // cf. https://cookiecutter.readthedocs.io/en/latest/advanced/template_extensions.html?highlight=jsonify#jsonify-extension - // cf. https://mozilla.github.io/nunjucks/templating.html#dump - templater.addFilter('jsonify', templater.getFilter('dump')); - } - // Cookiecutter prefixes all parameters in templates with // `cookiecutter.`. To replicate this, we wrap our parameters // in an object with a `cookiecutter` property when compat @@ -223,6 +180,10 @@ export function createFetchTemplateAction(options: { ctx.input.values, ); + const renderTemplate = await SecureTemplater.loadRenderer({ + cookiecutterCompat: ctx.input.cookiecutterCompat, + }); + for (const location of allEntriesInTemplate) { let renderFilename: boolean; let renderContents: boolean; @@ -238,9 +199,9 @@ export function createFetchTemplateAction(options: { renderFilename = renderContents = !nonTemplatedEntries.has(location); } if (renderFilename) { - localOutputPath = templater.renderString(localOutputPath, context); + localOutputPath = renderTemplate(localOutputPath, context); } - const outputPath = resolvePath(outputDir, localOutputPath); + const outputPath = resolveSafeChildPath(outputDir, localOutputPath); // variables have been expanded to make an empty file name // this is due to a conditional like if values.my_condition then file-name.txt else empty string so skip if (outputDir === outputPath) { @@ -259,7 +220,7 @@ export function createFetchTemplateAction(options: { ); await fs.ensureDir(outputPath); } else { - const inputFilePath = resolvePath(templateDir, location); + const inputFilePath = resolveSafeChildPath(templateDir, location); if (await isBinaryFile(inputFilePath)) { ctx.logger.info( @@ -275,7 +236,7 @@ export function createFetchTemplateAction(options: { await fs.outputFile( outputPath, renderContents - ? templater.renderString(inputFileContents, context) + ? renderTemplate(inputFileContents, context) : inputFileContents, { mode: statsObj.mode }, ); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts index eff65e329f..90f060fd88 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { spawn } from 'child_process'; +import { SpawnOptionsWithoutStdio, spawn } from 'child_process'; import { PassThrough, Writable } from 'stream'; import { Logger } from 'winston'; import { Git } from '@backstage/backend-common'; @@ -22,18 +22,27 @@ import { Octokit } from '@octokit/rest'; import { assertError } from '@backstage/errors'; export type RunCommandOptions = { + /** command to run */ command: string; + /** arguments to pass the command */ args: string[]; + /** options to pass to spawn */ + options?: SpawnOptionsWithoutStdio; + /** stream to capture stdout and stderr output */ logStream?: Writable; }; +/** + * Run a command in a sub-process, normally a shell command. + */ export const runCommand = async ({ command, args, logStream = new PassThrough(), + options, }: RunCommandOptions) => { await new Promise((resolve, reject) => { - const process = spawn(command, args); + const process = spawn(command, args, options); process.stdout.on('data', stream => { logStream.write(stream); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts index daa6bc249b..fbfe87b076 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -19,7 +19,7 @@ import { BitbucketIntegrationConfig, ScmIntegrationRegistry, } from '@backstage/integration'; -import fetch from 'cross-fetch'; +import fetch, { Response, RequestInit } from 'node-fetch'; import { initRepoAndPush } from '../helpers'; import { createTemplateAction } from '../../createTemplateAction'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts index 0cceb47d71..3a9dd01686 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -15,7 +15,6 @@ */ import fs from 'fs-extra'; -import path from 'path'; import { parseRepoUrl, isExecutable } from './util'; import { @@ -197,7 +196,7 @@ export const createPublishGithubPullRequestAction = ({ const fileContents = await Promise.all( localFilePaths.map(filePath => { - const absPath = path.resolve(fileRoot, filePath); + const absPath = resolveSafeChildPath(fileRoot, filePath); const base64EncodedContent = fs .readFileSync(absPath) .toString('base64'); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts index ca3459d29c..52149be724 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts @@ -18,6 +18,7 @@ import { Logger } from 'winston'; import { Writable } from 'stream'; import { JsonValue, JsonObject } from '@backstage/types'; import { Schema } from 'jsonschema'; +import { TemplateMetadata } from '../tasks/types'; type PartialJsonObject = Partial; type PartialJsonValue = PartialJsonObject | JsonValue | undefined; @@ -44,6 +45,8 @@ export type ActionContext = { * Creates a temporary directory for use by the action, which is then cleaned up automatically. */ createTemporaryDirectory(): Promise; + + metadata?: TemplateMetadata; }; export type TemplateAction = { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts index 77d0a7be09..5582b53023 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts @@ -225,6 +225,12 @@ export class HandlebarsWorkflowRunner implements WorkflowRunner { input: JSON.stringify(input, null, 2), }); + if (!task.spec.metadata) { + console.warn( + 'DEPRECATION NOTICE: metadata is undefined. metadata will be required in the future.', + ); + } + await action.handler({ baseUrl: task.spec.baseUrl, logger: taskLogger, @@ -242,6 +248,7 @@ export class HandlebarsWorkflowRunner implements WorkflowRunner { output(name: string, value: JsonValue) { stepOutputs[name] = value; }, + metadata: task.spec.metadata, }); // Remove all temporary directories that were created when executing the action diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts index 91e772dc4e..9b05c43831 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/LegacyWorkflowRunner.test.ts @@ -28,6 +28,7 @@ describe('LegacyWorkflowRunner', () => { let runner: HandlebarsWorkflowRunner; const logger = getVoidLogger(); let actionRegistry = new TemplateActionRegistry(); + let fakeActionHandler: jest.Mock; const integrations = ScmIntegrations.fromConfig( new ConfigReader({ @@ -59,6 +60,11 @@ describe('LegacyWorkflowRunner', () => { ctx.output('badOutput', false); }, }); + fakeActionHandler = jest.fn(); + actionRegistry.register({ + id: 'test-metadata-action', + handler: fakeActionHandler, + }); runner = new HandlebarsWorkflowRunner({ actionRegistry, @@ -87,6 +93,30 @@ describe('LegacyWorkflowRunner', () => { ); }); + it('should pass metadata through', async () => { + const templateName = 'template name'; + const task = createMockTaskWithSpec({ + apiVersion: 'backstage.io/v1beta2', + steps: [ + { + id: 'test', + name: 'name', + action: 'test-metadata-action', + input: { foo: 1 }, + }, + ], + output: {}, + values: {}, + metadata: { name: templateName }, + }); + + await runner.execute(task); + + expect(fakeActionHandler.mock.calls[0][0].metadata).toEqual({ + name: templateName, + }); + }); + describe('templating', () => { it('should template the output', async () => { const task = createMockTaskWithSpec({ diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DefaultWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts similarity index 87% rename from plugins/scaffolder-backend/src/scaffolder/tasks/DefaultWorkflowRunner.test.ts rename to plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index d07ff29fae..b933990953 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/DefaultWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -17,13 +17,24 @@ import mockFs from 'mock-fs'; import * as winston from 'winston'; -import { getVoidLogger } from '@backstage/backend-common'; +import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common'; import { NunjucksWorkflowRunner } from './NunjucksWorkflowRunner'; import { TemplateActionRegistry } from '../actions'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { TaskContext, TaskSpec } from './types'; +const realFiles = Object.fromEntries( + [ + require.resolve('vm2/lib/fixasync'), + resolvePackagePath( + '@backstage/plugin-scaffolder-backend', + 'assets', + 'nunjucks.js.txt', + ), + ].map(k => [k, mockFs.load(k)]), +); + describe('DefaultWorkflowRunner', () => { const logger = getVoidLogger(); let actionRegistry = new TemplateActionRegistry(); @@ -50,6 +61,7 @@ describe('DefaultWorkflowRunner', () => { winston.format.simple(); // put logform the require cache before mocking fs mockFs({ '/tmp': mockFs.directory(), + ...realFiles, }); jest.resetAllMocks(); @@ -146,6 +158,30 @@ describe('DefaultWorkflowRunner', () => { expect(fakeActionHandler).toHaveBeenCalledTimes(1); }); + + it('should pass metadata through', async () => { + const templateName = 'template name'; + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + parameters: {}, + output: {}, + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-validated-action', + input: { foo: 1 }, + }, + ], + metadata: { name: templateName }, + }); + + await runner.execute(task); + + expect(fakeActionHandler.mock.calls[0][0].metadata).toEqual({ + name: templateName, + }); + }); }); describe('conditionals', () => { @@ -246,6 +282,31 @@ describe('DefaultWorkflowRunner', () => { ); }); + it('should not try and parse something that is not parsable', async () => { + jest.spyOn(logger, 'error'); + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-mock-action', + input: { + foo: 'bob', + }, + }, + ], + output: {}, + parameters: { + input: 'BACKSTAGE', + }, + }); + + await runner.execute(task); + + expect(logger.error).not.toHaveBeenCalled(); + }); + it('should keep the original types for the input and not parse things that arent meant to be parsed', async () => { const task = createMockTaskWithSpec({ apiVersion: 'scaffolder.backstage.io/v1beta3', diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 587a93e1f2..a3e30c65fe 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { ScmIntegrations } from '@backstage/integration'; import { TaskContext, @@ -23,9 +24,9 @@ import { WorkflowRunner, } from './types'; import * as winston from 'winston'; -import nunjucks from 'nunjucks'; import fs from 'fs-extra'; import path from 'path'; +import nunjucks from 'nunjucks'; import { JsonObject, JsonValue } from '@backstage/types'; import { InputError } from '@backstage/errors'; import { PassThrough } from 'stream'; @@ -33,6 +34,10 @@ import { isTruthy } from './helper'; import { validate as validateJsonSchema } from 'jsonschema'; import { parseRepoUrl } from '../actions/builtin/publish/util'; import { TemplateActionRegistry } from '../actions'; +import { + SecureTemplater, + SecureTemplateRenderer, +} from '../../lib/templating/SecureTemplater'; type NunjucksWorkflowRunnerOptions = { workingDirectory: string; @@ -84,43 +89,43 @@ const createStepLogger = ({ }; export class NunjucksWorkflowRunner implements WorkflowRunner { - private readonly nunjucks: nunjucks.Environment; - - private readonly nunjucksOptions: nunjucks.ConfigureOptions = { - autoescape: false, - tags: { - variableStart: '${{', - variableEnd: '}}', - }, - }; - - constructor(private readonly options: NunjucksWorkflowRunnerOptions) { - this.nunjucks = nunjucks.configure(this.nunjucksOptions); - - // TODO(blam): let's work out how we can deprecate these. - // We shouldn't really need to be exposing these now we can deal with - // objects in the params block. - // Maybe we can expose a new RepoUrlPicker with secrets for V3 that provides an object already. - this.nunjucks.addFilter('parseRepoUrl', repoUrl => { - return parseRepoUrl(repoUrl, this.options.integrations); - }); - - this.nunjucks.addFilter('projectSlug', repoUrl => { - const { owner, repo } = parseRepoUrl(repoUrl, this.options.integrations); - return `${owner}/${repo}`; - }); - } + constructor(private readonly options: NunjucksWorkflowRunnerOptions) {} private isSingleTemplateString(input: string) { - const { parser, nodes } = require('nunjucks'); - const parsed = parser.parse(input, {}, this.nunjucksOptions); + const { parser, nodes } = nunjucks as unknown as { + parser: { + parse( + template: string, + ctx: object, + options: nunjucks.ConfigureOptions, + ): { children: { children?: unknown[] }[] }; + }; + nodes: { TemplateData: Function }; + }; + + const parsed = parser.parse( + input, + {}, + { + autoescape: false, + tags: { + variableStart: '${{', + variableEnd: '}}', + }, + }, + ); + return ( parsed.children.length === 1 && - !(parsed.children[0] instanceof nodes.TemplateData) + !(parsed.children[0]?.children?.[0] instanceof nodes.TemplateData) ); } - private render(input: T, context: TemplateContext): T { + private render( + input: T, + context: TemplateContext, + renderTemplate: SecureTemplateRenderer, + ): T { return JSON.parse(JSON.stringify(input), (_key, value) => { try { if (typeof value === 'string') { @@ -133,10 +138,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { ); // Run the templating - const templated = this.nunjucks.renderString( - wrappedDumped, - context, - ); + const templated = renderTemplate(wrappedDumped, context); // If there's an empty string returned, then it's undefined if (templated === '') { @@ -153,7 +155,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { } // Fallback to default behaviour - const templated = this.nunjucks.renderString(value, context); + const templated = renderTemplate(value, context); if (templated === '') { return undefined; @@ -178,6 +180,18 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { this.options.workingDirectory, await task.getWorkspaceName(), ); + + const { integrations } = this.options; + const renderTemplate = await SecureTemplater.loadRenderer({ + // TODO(blam): let's work out how we can deprecate this. + // We shouldn't really need to be exposing these now we can deal with + // objects in the params block. + // Maybe we can expose a new RepoUrlPicker with secrets for V3 that provides an object already. + parseRepoUrl(url: string) { + return parseRepoUrl(url, integrations); + }, + }); + try { await fs.ensureDir(workspacePath); await task.emitLog( @@ -192,7 +206,11 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { for (const step of task.spec.steps) { try { if (step.if) { - const ifResult = await this.render(step.if, context); + const ifResult = await this.render( + step.if, + context, + renderTemplate, + ); if (!isTruthy(ifResult)) { await task.emitLog( `Skipping step ${step.id} because it's if condition was false`, @@ -210,7 +228,9 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const action = this.options.actionRegistry.get(step.action); const { taskLogger, streamLogger } = createStepLogger({ task, step }); - const input = (step.input && this.render(step.input, context)) ?? {}; + const input = + (step.input && this.render(step.input, context, renderTemplate)) ?? + {}; if (action.schema?.input) { const validateResult = validateJsonSchema( @@ -228,6 +248,12 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const tmpDirs = new Array(); const stepOutput: { [outputName: string]: JsonValue } = {}; + if (!task.spec.metadata) { + console.warn( + 'DEPRECATION NOTICE: metadata is undefined. metadata will be required in the future.', + ); + } + await action.handler({ baseUrl: task.spec.baseUrl, input, @@ -244,6 +270,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { output(name: string, value: JsonValue) { stepOutput[name] = value; }, + metadata: task.spec.metadata, }); // Remove all temporary directories that were created when executing the action @@ -266,7 +293,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { } } - const output = this.render(task.spec.output, context); + const output = this.render(task.spec.output, context, renderTemplate); return { output }; } finally { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts index 510c862dae..f563a08df6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts @@ -34,4 +34,5 @@ export type { TaskContext, TaskStore, DispatchResult, + TemplateMetadata, } from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index 6cfd914bd2..b492f8ad2c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -69,6 +69,15 @@ export type SerializedTaskEvent = { createdAt: string; }; +/** + * TemplateMetadata + * + * @public + */ +export type TemplateMetadata = { + name: string; +}; + /** * TaskSpecV1beta2 * @@ -86,6 +95,7 @@ export interface TaskSpecV1beta2 { if?: string | boolean; }>; output: { [name: string]: string }; + metadata?: TemplateMetadata; } export interface TaskStep { @@ -107,6 +117,7 @@ export interface TaskSpecV1beta3 { parameters: JsonObject; steps: TaskStep[]; output: { [name: string]: JsonValue }; + metadata?: TemplateMetadata; } /** diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 2ae30593f2..b8f2968287 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -209,6 +209,7 @@ export async function createRouter( name: step.name ?? step.action, })), output: template.spec.output ?? {}, + metadata: { name: template.metadata?.name }, } : { apiVersion: template.apiVersion, @@ -220,6 +221,7 @@ export async function createRouter( name: step.name ?? step.action, })), output: template.spec.output ?? {}, + metadata: { name: template.metadata?.name }, }; } else { throw new InputError( diff --git a/plugins/scaffolder-common/package.json b/plugins/scaffolder-common/package.json index 006d8ad7aa..9099eda99d 100644 --- a/plugins/scaffolder-common/package.json +++ b/plugins/scaffolder-common/package.json @@ -36,10 +36,10 @@ "url": "https://github.com/backstage/backstage/issues" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-model": "^0.9.7", "@backstage/types": "^0.1.1" }, "devDependencies": { - "@backstage/cli": "^0.8.1" + "@backstage/cli": "^0.10.0" } } diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index dd5653240a..b445ec873c 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,54 @@ # @backstage/plugin-scaffolder +## 0.11.13 + +### Patch Changes + +- ed5bef529e: Add group filtering to the scaffolder page so that individuals can surface specific templates to end users ahead of others, or group templates together. This can be accomplished by passing in a `groups` prop to the `ScaffolderPage` + + ``` + + entity?.metadata?.tags?.includes('recommended') ?? false, + }, + ]} + /> + ``` + +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.11.12 + +### Patch Changes + +- 2d7d165737: Bump `react-jsonschema-form` +- 9f21236a29: Fixed a missing `await` when throwing server side errors +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.11.11 + +### Patch Changes + +- 8809b6c0dd: Update the json-schema dependency version. +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/integration-react@0.1.14 + ## 0.11.10 ### Patch Changes diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 0ac6910264..73fd8fae72 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -24,6 +24,7 @@ import { JSONSchema } from '@backstage/catalog-model'; import { JsonValue } from '@backstage/types'; import { Observable } from '@backstage/types'; import { default as React_2 } from 'react'; +import { ReactNode } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; @@ -213,12 +214,20 @@ export const ScaffolderFieldExtensions: React_2.ComponentType; // @public (undocumented) export const ScaffolderPage: ({ TemplateCardComponent, + groups, }: { TemplateCardComponent?: | ComponentType<{ template: TemplateEntityV1beta2; }> | undefined; + groups?: + | { + title?: string | undefined; + titleComponent?: ReactNode; + filter: (entity: Entity) => boolean; + }[] + | undefined; }) => JSX.Element; // Warning: (ae-missing-release-tag) "scaffolderPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -240,7 +249,8 @@ export { scaffolderPlugin }; // @public (undocumented) export const TemplateList: ({ TemplateCardComponent, -}: TemplateListProps) => JSX.Element; + group, +}: TemplateListProps) => JSX.Element | null; // Warning: (ae-missing-release-tag) "TemplateListProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -251,6 +261,11 @@ export type TemplateListProps = { template: TemplateEntityV1beta2; }> | undefined; + group?: { + title?: string; + titleComponent?: React_2.ReactNode; + filter: (entity: Entity) => boolean; + }; }; // Warning: (ae-missing-release-tag) "TemplateTypePicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 594a8e6423..404bfcd387 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-scaffolder", "description": "The Backstage plugin that helps you create new things", - "version": "0.11.10", + "version": "0.11.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,22 +31,22 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.5.1", - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", - "@backstage/integration-react": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.3", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/errors": "^0.1.5", + "@backstage/integration": "^0.6.10", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@rjsf/core": "^3.0.0", - "@rjsf/material-ui": "^3.0.0", + "@rjsf/core": "^3.2.1", + "@rjsf/material-ui": "^3.2.1", "@types/react": "*", "classnames": "^2.2.6", "git-url-parse": "^11.6.0", @@ -66,10 +66,10 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index a16ab59053..34e16708ea 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -187,7 +187,7 @@ export class ScaffolderClient implements ScaffolderApi { }); if (!response.ok) { - throw ResponseError.fromResponse(response); + throw await ResponseError.fromResponse(response); } return await response.json(); @@ -302,7 +302,7 @@ export class ScaffolderClient implements ScaffolderApi { }); if (!response.ok) { - throw ResponseError.fromResponse(response); + throw await ResponseError.fromResponse(response); } return await response.json(); diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 77c8240974..ce0354b817 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -17,8 +17,8 @@ import React from 'react'; import { ScaffolderApi, scaffolderApiRef } from '../../api'; import { ActionsPage } from './ActionsPage'; import { rootRouteRef } from '../../routes'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; +import { ApiProvider } from '@backstage/core-app-api'; const scaffolderApiMock: jest.Mocked = { scaffold: jest.fn(), @@ -29,7 +29,7 @@ const scaffolderApiMock: jest.Mocked = { listActions: jest.fn(), }; -const apis = ApiRegistry.from([[scaffolderApiRef, scaffolderApiMock]]); +const apis = TestApiRegistry.from([scaffolderApiRef, scaffolderApiMock]); describe('TemplatePage', () => { beforeEach(() => jest.resetAllMocks()); diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index 9e4736ab9f..e667c676f2 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -16,7 +16,7 @@ import React, { ComponentType } from 'react'; import { Routes, Route, useOutlet } from 'react-router'; -import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; +import { TemplateEntityV1beta2, Entity } from '@backstage/catalog-model'; import { ScaffolderPage } from './ScaffolderPage'; import { TemplatePage } from './TemplatePage'; import { TaskPage } from './TaskPage'; @@ -34,9 +34,14 @@ type RouterProps = { TemplateCardComponent?: | ComponentType<{ template: TemplateEntityV1beta2 }> | undefined; + groups?: Array<{ + title?: string; + titleComponent?: React.ReactNode; + filter: (entity: Entity) => boolean; + }>; }; -export const Router = ({ TemplateCardComponent }: RouterProps) => { +export const Router = ({ TemplateCardComponent, groups }: RouterProps) => { const outlet = useOutlet(); const customFieldExtensions = useElementFilter(outlet, elements => @@ -64,7 +69,10 @@ export const Router = ({ TemplateCardComponent }: RouterProps) => { + } /> | undefined; + groups?: Array<{ + title?: string; + titleComponent?: React.ReactNode; + filter: (entity: Entity) => boolean; + }>; }; export const ScaffolderPageContents = ({ TemplateCardComponent, + groups, }: ScaffolderPageProps) => { const styles = useStyles(); - const registerComponentLink = useRouteRef(registerComponentRouteRef); + const otherTemplatesGroup = { + title: groups ? 'Other Templates' : 'Templates', + filter: (entity: Entity) => { + const filtered = (groups ?? []).map(group => group.filter(entity)); + return !filtered.some(result => result === true); + }, + }; return ( @@ -96,7 +108,17 @@ export const ScaffolderPageContents = ({
- + {groups && + groups.map(group => ( + + ))} +
@@ -106,8 +128,12 @@ export const ScaffolderPageContents = ({ export const ScaffolderPage = ({ TemplateCardComponent, + groups, }: ScaffolderPageProps) => ( - + ); diff --git a/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx b/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx index dcf708db10..9b66a0a9b6 100644 --- a/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx +++ b/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx @@ -16,10 +16,13 @@ import React, { ComponentType } from 'react'; import { + Entity, stringifyEntityRef, TemplateEntityV1beta2, } from '@backstage/catalog-model'; import { + Content, + ContentHeader, ItemCardGrid, Progress, WarningPanel, @@ -32,11 +35,31 @@ export type TemplateListProps = { TemplateCardComponent?: | ComponentType<{ template: TemplateEntityV1beta2 }> | undefined; + group?: { + title?: string; + titleComponent?: React.ReactNode; + filter: (entity: Entity) => boolean; + }; }; -export const TemplateList = ({ TemplateCardComponent }: TemplateListProps) => { +export const TemplateList = ({ + TemplateCardComponent, + group, +}: TemplateListProps) => { const { loading, error, entities } = useEntityListProvider(); const Card = TemplateCardComponent || TemplateCard; + const maybeFilteredEntities = group + ? entities.filter(e => group.filter(e)) + : entities; + const title = group ? ( + group.titleComponent || + ) : ( + + ); + + if (group && maybeFilteredEntities.length === 0) { + return null; + } return ( <> {loading && } @@ -57,16 +80,19 @@ export const TemplateList = ({ TemplateCardComponent }: TemplateListProps) => { )} - - {entities && - entities?.length > 0 && - entities.map(template => ( - - ))} - + + {title} + + {maybeFilteredEntities && + maybeFilteredEntities?.length > 0 && + maybeFilteredEntities.map((template: Entity) => ( + + ))} + + ); }; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index e42bcbdbdc..60e9ee8840 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -13,7 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { renderInTestApp, renderWithEffects } from '@backstage/test-utils'; +import { + renderInTestApp, + renderWithEffects, + TestApiRegistry, +} from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import { fireEvent, within } from '@testing-library/react'; @@ -24,7 +28,7 @@ import { ScaffolderApi, scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; import { TemplatePage } from './TemplatePage'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; jest.mock('react-router-dom', () => { @@ -47,10 +51,10 @@ const scaffolderApiMock: jest.Mocked = { const errorApiMock = { post: jest.fn(), error$: jest.fn() }; -const apis = ApiRegistry.from([ +const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], [errorApiRef, errorApiMock], -]); +); describe('TemplatePage', () => { beforeEach(() => jest.resetAllMocks()); diff --git a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx index 3d0267ff1f..c70f251eac 100644 --- a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx +++ b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx @@ -26,8 +26,8 @@ import { MockEntityListContextProvider, } from '@backstage/plugin-catalog-react'; import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; -import { renderWithEffects } from '@backstage/test-utils'; +import { ApiProvider } from '@backstage/core-app-api'; +import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils'; const entities: Entity[] = [ { @@ -62,7 +62,7 @@ const entities: Entity[] = [ }, ]; -const apis = ApiRegistry.from([ +const apis = TestApiRegistry.from( [ catalogApiRef, { @@ -77,7 +77,7 @@ const apis = ApiRegistry.from([ post: jest.fn(), } as unknown as AlertApi, ], -]); +); describe('', () => { it('renders available entity types', async () => { diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx index f293969dff..b7e72e3583 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx @@ -16,12 +16,11 @@ import { Entity } from '@backstage/catalog-model'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { FieldProps } from '@rjsf/core'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { EntityPicker } from './EntityPicker'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ apiVersion: 'backstage.io/v1beta1', @@ -53,14 +52,15 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); entities = [ makeEntity('Group', 'default', 'team-a'), makeEntity('Group', 'default', 'squad-b'), ]; Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx index 18d6fd7e5c..31992fbc8a 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx @@ -16,11 +16,10 @@ import { Entity } from '@backstage/catalog-model'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { FieldProps } from '@rjsf/core'; import React from 'react'; import { OwnerPicker } from './OwnerPicker'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ apiVersion: 'backstage.io/v1beta1', @@ -50,14 +49,15 @@ describe('', () => { let Wrapper: React.ComponentType; beforeEach(() => { - const apis = ApiRegistry.with(catalogApiRef, catalogApi); entities = [ makeEntity('Group', 'default', 'team-a'), makeEntity('Group', 'default', 'squad-b'), ]; Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index f862fa66fe..12931c297f 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -27,7 +27,7 @@ import FormHelperText from '@material-ui/core/FormHelperText'; import { useApi } from '@backstage/core-plugin-api'; import { Progress } from '@backstage/core-components'; -function splitFormData(url: string | undefined) { +function splitFormData(url: string | undefined, allowedOwners: string[]) { let host = undefined; let owner = undefined; let repo = undefined; @@ -39,7 +39,10 @@ function splitFormData(url: string | undefined) { if (url) { const parsed = new URL(`https://${url}`); host = parsed.host; - owner = parsed.searchParams.get('owner') || undefined; + owner = + parsed.searchParams.get('owner') || allowedOwners + ? allowedOwners[0] + : undefined; repo = parsed.searchParams.get('repo') || undefined; // This is azure dev ops specific. not used for any other provider. organization = parsed.searchParams.get('organization') || undefined; @@ -95,13 +98,16 @@ export const RepoUrlPicker = ({ const scaffolderApi = useApi(scaffolderApiRef); const integrationApi = useApi(scmIntegrationsApiRef); const allowedHosts = uiSchema['ui:options']?.allowedHosts as string[]; + const allowedOwners = uiSchema['ui:options']?.allowedOwners as string[]; const { value: integrations, loading } = useAsync(async () => { return await scaffolderApi.getIntegrationsList({ allowedHosts }); }); - const { host, owner, repo, organization, workspace, project } = - splitFormData(formData); + const { host, owner, repo, organization, workspace, project } = splitFormData( + formData, + allowedOwners, + ); const updateHost = useCallback( (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => { onChange( @@ -300,21 +306,57 @@ export const RepoUrlPicker = ({ )} {/* Show this for all hosts except bitbucket */} - {host && integrationApi.byHost(host)?.type !== 'bitbucket' && ( - <> - 0 && !owner} - > - Owner - - - The organization, user or project that this repo will belong to - - - - )} + {host && + integrationApi.byHost(host)?.type !== 'bitbucket' && + !allowedOwners && ( + <> + 0 && !owner} + > + Owner + + + The organization, user or project that this repo will belong to + + + + )} + {/* Show this for all hosts except bitbucket where allowed owner is set */} + {host && + integrationApi.byHost(host)?.type !== 'bitbucket' && + allowedOwners && ( + <> + 0 && !owner} + > + Owner Available + + + The organization, user or project that this repo will belong to + + + + )} {/* Show this for all hosts */} ) => ( + + + + - + + + + + + + + ... + ``` + +### Patch Changes + +- f06ecd09a7: Add optional icon and secondaryAction properties for DefaultResultListItem component +- c5941d5c30: Add a new optional clearButton property to the SearchBar component. The default value for this new property is true. +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.4.18 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- 704b267e1c: Smaller UX improvements to search components such as optional autoFocus prop to SearchBar components, decreased debounce value and closing modal on link click of SearchModal, terminology updates of SearchResultPager. +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.4.17 ### Patch Changes diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index 0e0ad01fca..3db26579e3 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -12,6 +12,7 @@ import { IndexableDocument } from '@backstage/search-common'; import { JsonObject } from '@backstage/types'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; +import { ReactNode } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { SearchQuery } from '@backstage/search-common'; import { SearchResult as SearchResult_2 } from '@backstage/search-common'; @@ -22,7 +23,11 @@ import { SearchResultSet } from '@backstage/search-common'; // @public (undocumented) export const DefaultResultListItem: ({ result, + icon, + secondaryAction, }: { + icon?: ReactNode; + secondaryAction?: ReactNode; result: IndexableDocument; }) => JSX.Element; @@ -69,7 +74,14 @@ export const HomePageSearchBar: ({ // @public (undocumented) export const Router: () => JSX.Element; -// Warning: (ae-forgotten-export) The symbol "SearchApi" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "SearchApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface SearchApi { + // (undocumented) + query(query: SearchQuery): Promise; +} + // Warning: (ae-missing-release-tag) "searchApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -80,22 +92,28 @@ export const searchApiRef: ApiRef; // // @public (undocumented) export const SearchBar: ({ + autoFocus, className, debounceTime, placeholder, + clearButton, }: Props) => JSX.Element; // Warning: (ae-missing-release-tag) "SearchBarNext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @deprecated (undocumented) export const SearchBarNext: ({ + autoFocus, className, debounceTime, placeholder, + clearButton, }: { + autoFocus?: boolean | undefined; className?: string | undefined; debounceTime?: number | undefined; placeholder?: string | undefined; + clearButton?: boolean | undefined; }) => JSX.Element; // Warning: (ae-missing-release-tag) "SearchContextProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -210,7 +228,7 @@ export const useSearch: () => SearchContextValue; // Warnings were encountered during analysis: // -// src/components/SearchContext/SearchContext.d.ts:21:5 - (ae-forgotten-export) The symbol "SettableSearchContext" needs to be exported by the entry point index.d.ts +// src/components/SearchContext/SearchContext.d.ts:23:5 - (ae-forgotten-export) The symbol "SettableSearchContext" needs to be exported by the entry point index.d.ts // src/components/SearchFilter/SearchFilter.d.ts:13:5 - (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // src/components/SearchFilter/SearchFilter.d.ts:14:5 - (ae-forgotten-export) The symbol "Component" needs to be exported by the entry point index.d.ts ``` diff --git a/plugins/search/package.json b/plugins/search/package.json index 9d25ec723d..1804a7aca8 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-search", "description": "The Backstage plugin that provides your backstage app with search", - "version": "0.4.17", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,14 +30,14 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", "@backstage/errors": "^0.1.4", - "@backstage/plugin-catalog-react": "^0.6.3", + "@backstage/plugin-catalog-react": "^0.6.4", "@backstage/search-common": "^0.2.1", - "@backstage/theme": "^0.2.13", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -51,10 +51,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx index 34436f9f93..0445a35943 100644 --- a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx +++ b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx @@ -16,6 +16,9 @@ import React from 'react'; import { Grid } from '@material-ui/core'; +import FindInPageIcon from '@material-ui/icons/FindInPage'; +import GroupIcon from '@material-ui/icons/Group'; +import { Button } from '@backstage/core-components'; import { DefaultResultListItem } from '../index'; import { MemoryRouter } from 'react-router'; @@ -24,17 +27,59 @@ export default { component: DefaultResultListItem, }; +const mockSearchResult = { + location: 'search/search-result', + title: 'Search Result 1', + text: 'some text from the search result', + owner: 'some-example-owner', +}; + export const Default = () => { + return ( + + + + + + + + ); +}; + +export const WithIcon = () => { return ( } + /> + + + + ); +}; + +export const WithSecondaryAction = () => { + return ( + + + + } + style={{ textTransform: 'lowercase' }} + > + {mockSearchResult.owner} + + } /> diff --git a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.jsx b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.jsx index 97698487ee..0a8e413107 100644 --- a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.jsx +++ b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.jsx @@ -17,7 +17,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { renderInTestApp } from '@backstage/test-utils'; - +import FindInPageIcon from '@material-ui/icons/FindInPage'; import { DefaultResultListItem } from './DefaultResultListItem'; describe('DefaultResultListItem', () => { @@ -25,6 +25,7 @@ describe('DefaultResultListItem', () => { title: 'title', text: 'text', location: '/location', + owner: 'owner', }; it('Links to result.location', async () => { @@ -38,4 +39,21 @@ describe('DefaultResultListItem', () => { result.title + result.text, ); }); + + it('should render icon if prop is specified', async () => { + await renderInTestApp( + } + />, + ); + expect(screen.getByTitle('icon')).toBeInTheDocument(); + }); + + it('should render secondary action if prop is specified', async () => { + await renderInTestApp( + , + ); + expect(screen.getByText('owner')).toBeInTheDocument(); + }); }); diff --git a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx index ed508aa671..dd541b6e21 100644 --- a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx +++ b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx @@ -14,24 +14,38 @@ * limitations under the License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { IndexableDocument } from '@backstage/search-common'; -import { ListItem, ListItemText, Divider } from '@material-ui/core'; +import { + ListItem, + ListItemIcon, + ListItemText, + Box, + Divider, +} from '@material-ui/core'; import { Link } from '@backstage/core-components'; type Props = { + icon?: ReactNode; + secondaryAction?: ReactNode; result: IndexableDocument; }; -export const DefaultResultListItem = ({ result }: Props) => { +export const DefaultResultListItem = ({ + result, + icon, + secondaryAction, +}: Props) => { return ( - + + {icon && {icon}} + {secondaryAction && {secondaryAction}} diff --git a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx b/plugins/search/src/components/SearchBar/SearchBar.stories.tsx index 2c990e512c..d00f391bc7 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.stories.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { Paper, Grid } from '@material-ui/core'; +import { Paper, Grid, makeStyles } from '@material-ui/core'; import { SearchBar, SearchContext } from '../index'; import { MemoryRouter } from 'react-router'; @@ -62,3 +62,67 @@ export const CustomPlaceholder = () => { ); }; + +export const Focused = () => { + return ( + + {/* @ts-ignore (defaultValue requires more than what is used here) */} + + + + + {/* decision up to adopter, read https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md#no-autofocus */} + {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + + + + + + + ); +}; + +export const WithoutClearButton = () => { + return ( + + {/* @ts-ignore (defaultValue requires more than what is used here) */} + + + + + + + + + + + ); +}; + +const useStyles = makeStyles({ + search: { + display: 'flex', + justifyContent: 'space-between', + padding: '8px 0', + borderRadius: '50px', + margin: 'auto', + }, +}); + +export const CustomStyles = () => { + const classes = useStyles(); + return ( + + {/* @ts-ignore (defaultValue requires more than what is used here) */} + + + + + + + + + + + ); +}; diff --git a/plugins/search/src/components/SearchBar/SearchBar.test.tsx b/plugins/search/src/components/SearchBar/SearchBar.test.tsx index 3a11daa9fa..4ac4c95224 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.test.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.test.tsx @@ -21,12 +21,9 @@ import { SearchContextProvider } from '../SearchContext'; import { SearchBar } from './SearchBar'; import { configApiRef } from '@backstage/core-plugin-api'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { searchApiRef } from '../../apis'; +import { TestApiRegistry } from '@backstage/test-utils'; jest.mock('@backstage/core-plugin-api', () => ({ ...jest.requireActual('@backstage/core-plugin-api'), @@ -42,10 +39,10 @@ describe('SearchBar', () => { const query = jest.fn().mockResolvedValue({}); - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [configApiRef, new ConfigReader({ app: { title: 'Mock title' } })], [searchApiRef, { query }], - ]); + ); const name = 'Search'; const term = 'term'; @@ -152,6 +149,20 @@ describe('SearchBar', () => { ); }); + it('Should not show clear button', async () => { + render( + + + + + , + ); + + expect( + screen.queryByRole('button', { name: 'Clear' }), + ).not.toBeInTheDocument(); + }); + it('Adheres to provided debounceTime', async () => { jest.useFakeTimers(); diff --git a/plugins/search/src/components/SearchBar/SearchBar.tsx b/plugins/search/src/components/SearchBar/SearchBar.tsx index 53802899eb..693cf98adf 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.tsx @@ -30,14 +30,18 @@ type PresenterProps = { onSubmit?: () => void; className?: string; placeholder?: string; + autoFocus?: boolean; + clearButton?: boolean; }; export const SearchBarBase = ({ + autoFocus, value, onChange, onSubmit, className, placeholder: overridePlaceholder, + clearButton = true, }: PresenterProps) => { const configApi = useApi(configApiRef); @@ -60,6 +64,9 @@ export const SearchBarBase = ({ return ( } endAdornment={ - - - - - + clearButton && ( + + + + + + ) } {...(className && { className })} {...(onSubmit && { onKeyDown })} @@ -87,15 +96,19 @@ export const SearchBarBase = ({ }; type Props = { + autoFocus?: boolean; className?: string; debounceTime?: number; placeholder?: string; + clearButton?: boolean; }; export const SearchBar = ({ + autoFocus, className, debounceTime = 0, placeholder, + clearButton = true, }: Props) => { const { term, setTerm } = useSearch(); const [value, setValue] = useState(term); @@ -114,11 +127,15 @@ export const SearchBar = ({ return ( ); }; diff --git a/plugins/search/src/components/SearchContext/SearchContext.tsx b/plugins/search/src/components/SearchContext/SearchContext.tsx index 11d6a2786e..dd9310d782 100644 --- a/plugins/search/src/components/SearchContext/SearchContext.tsx +++ b/plugins/search/src/components/SearchContext/SearchContext.tsx @@ -37,6 +37,8 @@ type SearchContextValue = { setTypes: React.Dispatch>; filters: JsonObject; setFilters: React.Dispatch>; + open?: boolean; + toggleModal: () => void; pageCursor?: string; setPageCursor: React.Dispatch>; fetchNextPage?: React.DispatchWithoutAction; @@ -49,6 +51,7 @@ type SettableSearchContext = Omit< | 'setTerm' | 'setTypes' | 'setFilters' + | 'toggleModal' | 'setPageCursor' | 'fetchNextPage' | 'fetchPreviousPage' @@ -74,6 +77,12 @@ export const SearchContextProvider = ({ const [filters, setFilters] = useState(initialState.filters); const [term, setTerm] = useState(initialState.term); const [types, setTypes] = useState(initialState.types); + const [open, setOpen] = useState(false); + const toggleModal = useCallback( + (): void => setOpen(prevState => !prevState), + [], + ); + const prevTerm = usePrevious(term); const result = useAsync( @@ -109,6 +118,8 @@ export const SearchContextProvider = ({ result, filters, setFilters, + open, + toggleModal, term, setTerm, types, diff --git a/plugins/search/src/components/SearchModal/SearchModal.stories.tsx b/plugins/search/src/components/SearchModal/SearchModal.stories.tsx new file mode 100644 index 0000000000..6fa93c6588 --- /dev/null +++ b/plugins/search/src/components/SearchModal/SearchModal.stories.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentType } from 'react'; +import { Button } from '@material-ui/core'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { SearchModal } from '../index'; +import { useSearch, SearchContextProvider } from '../SearchContext'; +import { searchApiRef } from '../../apis'; +import { rootRouteRef } from '../../plugin'; + +const mockSearchApi = { + query: () => + Promise.resolve({ + results: [ + { + type: 'custom-result-item', + document: { + location: 'search/search-result-1', + title: 'Search Result 1', + text: 'some text from the search result', + }, + }, + { + type: 'no-custom-result-item', + document: { + location: 'search/search-result-2', + title: 'Search Result 2', + text: 'some text from the search result', + }, + }, + { + type: 'no-custom-result-item', + document: { + location: 'search/search-result-3', + title: 'Search Result 3', + text: 'some text from the search result', + }, + }, + ], + }), +}; + +const apiRegistry = () => ApiRegistry.from([[searchApiRef, mockSearchApi]]); + +export default { + title: 'Plugins/Search/SearchModal', + component: SearchModal, + decorators: [ + (Story: ComponentType<{}>) => + wrapInTestApp( + <> + + + + + + , + { mountedRoutes: { '/search': rootRouteRef } }, + ), + ], +}; + +export const Default = () => { + const { open, toggleModal } = useSearch(); + + return ( + <> + + + + ); +}; diff --git a/plugins/search/src/components/SearchModal/SearchModal.test.tsx b/plugins/search/src/components/SearchModal/SearchModal.test.tsx index d7f873abf3..ff9622dfb9 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.test.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.test.tsx @@ -15,14 +15,10 @@ */ import React from 'react'; import { screen } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import userEvent from '@testing-library/user-event'; import { configApiRef } from '@backstage/core-plugin-api'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { rootRouteRef } from '../../plugin'; import { searchApiRef } from '../../apis'; @@ -38,10 +34,10 @@ jest.mock('../SearchContext', () => ({ describe('SearchModal', () => { const query = jest.fn().mockResolvedValue({}); - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [configApiRef, new ConfigReader({ app: { title: 'Mock app' } })], [searchApiRef, { query }], - ]); + ); const toggleModal = jest.fn(); diff --git a/plugins/search/src/components/SearchModal/SearchModal.tsx b/plugins/search/src/components/SearchModal/SearchModal.tsx index feabbc55af..75d4b07a3c 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.tsx @@ -68,7 +68,7 @@ export const Modal = ({ open = true, toggleModal }: SearchModalProps) => { setValue(prevValue => (prevValue !== term ? term : prevValue)); }, [term]); - useDebounce(() => setTerm(value), 1000, [value]); + useDebounce(() => setTerm(value), 500, [value]); const handleQuery = (newValue: string) => { setValue(newValue); @@ -114,7 +114,10 @@ export const Modal = ({ open = true, toggleModal }: SearchModalProps) => { alignItems="center" > - + View Full Results diff --git a/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx b/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx index f1850772bb..12f07530ef 100644 --- a/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx +++ b/plugins/search/src/components/SearchResultPager/SearchResultPager.tsx @@ -25,7 +25,7 @@ const useStyles = makeStyles(theme => ({ display: 'flex', justifyContent: 'space-between', gap: theme.spacing(2), - margin: theme.spacing(4), + margin: theme.spacing(2, 0), }, })); @@ -44,9 +44,8 @@ export const SearchResultPager = () => { disabled={!fetchPreviousPage} onClick={fetchPreviousPage} startIcon={} - size="small" > - Back + Previous diff --git a/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx b/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx index 4d4fa1a9fc..ae38526c9d 100644 --- a/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx +++ b/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState } from 'react'; +import React from 'react'; import SearchIcon from '@material-ui/icons/Search'; -import { SearchModal } from '../SearchModal'; import { SidebarItem } from '@backstage/core-components'; +import { SearchModal } from '../SearchModal'; +import { useSearch } from '../SearchContext'; export const SidebarSearchModal = () => { - const [open, setOpen] = useState(false); - const toggleModal = (): void => setOpen(prevState => !prevState); + const { open, toggleModal } = useSearch(); return ( <> diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index 09cf204017..f5c58450fc 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -21,6 +21,7 @@ */ export { searchApiRef } from './apis'; +export type { SearchApi } from './apis'; export { Filters, FiltersButton, diff --git a/plugins/search/src/plugin.ts b/plugins/search/src/plugin.ts index b8c016fb36..9dda2a84bb 100644 --- a/plugins/search/src/plugin.ts +++ b/plugins/search/src/plugin.ts @@ -26,13 +26,11 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/search', - title: 'search', + id: 'search', }); export const rootNextRouteRef = createRouteRef({ - path: '/search-next', - title: 'search', + id: 'search:next', }); export const searchPlugin = createPlugin({ diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md index b0bcaad261..cad08289ed 100644 --- a/plugins/sentry/CHANGELOG.md +++ b/plugins/sentry/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-sentry +## 0.3.29 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.28 ### Patch Changes diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index ee2421bf7f..bb99225479 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-sentry", "description": "A Backstage plugin that integrates towards Sentry", - "version": "0.3.28", + "version": "0.3.29", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,11 +32,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.3", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -48,10 +48,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/sentry/src/plugin.ts b/plugins/sentry/src/plugin.ts index 454c1f02a6..981745c8fe 100644 --- a/plugins/sentry/src/plugin.ts +++ b/plugins/sentry/src/plugin.ts @@ -25,8 +25,7 @@ import { } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - path: '/sentry', - title: 'Sentry', + id: 'sentry', }); export const sentryPlugin = createPlugin({ diff --git a/plugins/shortcuts/CHANGELOG.md b/plugins/shortcuts/CHANGELOG.md index 41581cc5b4..fdd9f44231 100644 --- a/plugins/shortcuts/CHANGELOG.md +++ b/plugins/shortcuts/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-shortcuts +## 0.1.14 + +### Patch Changes + +- f93e56ae3a: Add tooltip to display shortcut title on hover. This should improve the readability of shortcuts with long names, which are cutoff by ellipsis. +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.13 ### Patch Changes diff --git a/plugins/shortcuts/package.json b/plugins/shortcuts/package.json index e73e047bc1..f1f41854ef 100644 --- a/plugins/shortcuts/package.json +++ b/plugins/shortcuts/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-shortcuts", "description": "A Backstage plugin that provides a shortcuts feature to the sidebar", - "version": "0.1.13", + "version": "0.1.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -38,10 +38,10 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/shortcuts/src/ShortcutItem.test.tsx b/plugins/shortcuts/src/ShortcutItem.test.tsx index 0b5c5fc919..e080026f00 100644 --- a/plugins/shortcuts/src/ShortcutItem.test.tsx +++ b/plugins/shortcuts/src/ShortcutItem.test.tsx @@ -37,7 +37,7 @@ describe('ShortcutItem', () => { it('displays the shortcut', async () => { await renderInTestApp( - + {} }}> , ); diff --git a/plugins/shortcuts/src/ShortcutItem.tsx b/plugins/shortcuts/src/ShortcutItem.tsx index d2ac716737..8ba1a96358 100644 --- a/plugins/shortcuts/src/ShortcutItem.tsx +++ b/plugins/shortcuts/src/ShortcutItem.tsx @@ -15,7 +15,9 @@ */ import React from 'react'; -import { IconButton, makeStyles } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; import EditIcon from '@material-ui/icons/Edit'; import { ShortcutIcon } from './ShortcutIcon'; import { EditShortcut } from './EditShortcut'; @@ -76,21 +78,23 @@ export const ShortcutItem = ({ shortcut, api }: Props) => { return ( <> - } - > - + } > - - - + + + + + { it('displays an add button', async () => { await renderInTestApp( - - + {} }}> + - + , ); await waitFor(() => !screen.queryByTestId('progress')); diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 8886ca3645..4cfc821482 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-sonarqube +## 0.2.8 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- 59677fadb1: Improvements to API Reference documentation +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.2.7 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.6 ### Patch Changes diff --git a/plugins/sonarqube/api-report.md b/plugins/sonarqube/api-report.md index 642e1d956f..b23ed65ddd 100644 --- a/plugins/sonarqube/api-report.md +++ b/plugins/sonarqube/api-report.md @@ -53,6 +53,4 @@ export { sonarQubePlugin }; // Warnings were encountered during analysis: // // src/components/SonarQubeCard/SonarQubeCard.d.ts:11:5 - (ae-forgotten-export) The symbol "DuplicationRating" needs to be exported by the entry point index.d.ts - -// (No @packageDocumentation comment for this package) ``` diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index bb55e68954..36c903701f 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-sonarqube", "description": "", - "version": "0.2.6", + "version": "0.2.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,11 +33,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -49,16 +49,15 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", - "cross-fetch": "^3.0.6", "msw": "^0.35.0" }, "files": [ diff --git a/plugins/sonarqube/src/index.ts b/plugins/sonarqube/src/index.ts index dcf0417151..1e6f83aa49 100644 --- a/plugins/sonarqube/src/index.ts +++ b/plugins/sonarqube/src/index.ts @@ -14,6 +14,13 @@ * limitations under the License. */ +/** + * A Backstage plugin to display {@link https://www.sonarqube.org | SonarQube} + * code quality and security results. + * + * @packageDocumentation + */ + export * from './components'; export { sonarQubePlugin, diff --git a/plugins/splunk-on-call/CHANGELOG.md b/plugins/splunk-on-call/CHANGELOG.md index 4bd0b9e9bc..b6d77a50cc 100644 --- a/plugins/splunk-on-call/CHANGELOG.md +++ b/plugins/splunk-on-call/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-splunk-on-call +## 0.3.15 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.14 ### Patch Changes diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json index 9d9ffdb370..28ce356d9c 100644 --- a/plugins/splunk-on-call/package.json +++ b/plugins/splunk-on-call/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-splunk-on-call", "description": "A Backstage plugin that integrates towards Splunk On-Call", - "version": "0.3.14", + "version": "0.3.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,11 +31,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -47,10 +47,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx index 63d617aff2..eb5c99d4e7 100644 --- a/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx +++ b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { splunkOnCallApiRef, SplunkOnCallClient, @@ -37,13 +37,8 @@ import { alertApiRef, ConfigApi, configApiRef, - createApiRef, } from '@backstage/core-plugin-api'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; const mockSplunkOnCallApi: Partial = { getUsers: async () => [], @@ -59,17 +54,11 @@ const configApi: ConfigApi = new ConfigReader({ }, }); -const apis = ApiRegistry.from([ +const apis = TestApiRegistry.from( [splunkOnCallApiRef, mockSplunkOnCallApi], [configApiRef, configApi], - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], -]); + [alertApiRef, {}], +); const mockEntity = { apiVersion: 'backstage.io/v1alpha1', diff --git a/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx b/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx index 1319493742..046c6a37f9 100644 --- a/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx +++ b/plugins/splunk-on-call/src/components/Escalation/Escalation.test.tsx @@ -16,15 +16,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { EscalationPolicy } from './EscalationPolicy'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { splunkOnCallApiRef } from '../../api'; import { MOCKED_ON_CALL, MOCKED_USER } from '../../api/mocks'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const mockSplunkOnCallApi = { - getOnCallUsers: () => [], + getOnCallUsers: jest.fn(), }; -const apis = ApiRegistry.from([[splunkOnCallApiRef, mockSplunkOnCallApi]]); +const apis = TestApiRegistry.from([splunkOnCallApiRef, mockSplunkOnCallApi]); describe('Escalation', () => { it('Handles an empty response', async () => { diff --git a/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx b/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx index df4254d3ad..181c2fda77 100644 --- a/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx +++ b/plugins/splunk-on-call/src/components/Incident/Incidents.test.tsx @@ -16,43 +16,39 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { Incidents } from './Incidents'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { splunkOnCallApiRef } from '../../api'; import { MOCK_TEAM, MOCK_INCIDENT } from '../../api/mocks'; import { alertApiRef, - createApiRef, IdentityApi, identityApiRef, } from '@backstage/core-plugin-api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; const mockIdentityApi: Partial = { getUserId: () => 'test', }; const mockSplunkOnCallApi = { - getIncidents: () => [], - getTeams: () => [], + getIncidents: jest.fn(), + getTeams: jest.fn(), }; -const apis = ApiRegistry.from([ - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], +const apis = TestApiRegistry.from( + [alertApiRef, {}], [identityApiRef, mockIdentityApi], [splunkOnCallApiRef, mockSplunkOnCallApi], -]); +); describe('Incidents', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + it('Renders an empty state when there are no incidents', async () => { - mockSplunkOnCallApi.getTeams = jest - .fn() - .mockImplementationOnce(async () => [MOCK_TEAM]); + mockSplunkOnCallApi.getIncidents.mockResolvedValue([]); + mockSplunkOnCallApi.getTeams.mockResolvedValue([MOCK_TEAM]); const { getByText, queryByTestId } = render( wrapInTestApp( @@ -69,13 +65,9 @@ describe('Incidents', () => { }); it('Renders all incidents', async () => { - mockSplunkOnCallApi.getIncidents = jest - .fn() - .mockImplementationOnce(async () => [MOCK_INCIDENT]); + mockSplunkOnCallApi.getIncidents.mockResolvedValue([MOCK_INCIDENT]); + mockSplunkOnCallApi.getTeams.mockResolvedValue([MOCK_TEAM]); - mockSplunkOnCallApi.getTeams = jest - .fn() - .mockImplementationOnce(async () => [MOCK_TEAM]); const { getByText, getByTitle, @@ -108,9 +100,10 @@ describe('Incidents', () => { }); it('Handle errors', async () => { - mockSplunkOnCallApi.getIncidents = jest - .fn() - .mockRejectedValueOnce(new Error('Error occurred')); + mockSplunkOnCallApi.getIncidents.mockRejectedValueOnce( + new Error('Error occurred'), + ); + mockSplunkOnCallApi.getTeams.mockResolvedValue([]); const { getByText, queryByTestId } = render( wrapInTestApp( diff --git a/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx index 94fd40bfe5..0b64a137af 100644 --- a/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx +++ b/plugins/splunk-on-call/src/components/TriggerDialog/TriggerDialog.test.tsx @@ -15,12 +15,12 @@ */ import React from 'react'; import { render, fireEvent, act } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { splunkOnCallApiRef } from '../../api'; import { TriggerDialog } from './TriggerDialog'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; -import { alertApiRef, createApiRef } from '@backstage/core-plugin-api'; +import { ApiProvider } from '@backstage/core-app-api'; +import { alertApiRef } from '@backstage/core-plugin-api'; describe('TriggerDialog', () => { const mockTriggerAlarmFn = jest.fn(); @@ -28,16 +28,10 @@ describe('TriggerDialog', () => { incidentAction: mockTriggerAlarmFn, }; - const apis = ApiRegistry.from([ - [ - alertApiRef, - createApiRef({ - id: 'core.alert', - description: 'Used to report alerts and forward them to the app', - }), - ], + const apis = TestApiRegistry.from( + [alertApiRef, {}], [splunkOnCallApiRef, mockSplunkOnCallApi], - ]); + ); it('open the dialog and trigger an alarm', async () => { const { getByText, getByRole, getByTestId } = render( diff --git a/plugins/splunk-on-call/src/plugin.ts b/plugins/splunk-on-call/src/plugin.ts index 0b54538f06..2256b382ee 100644 --- a/plugins/splunk-on-call/src/plugin.ts +++ b/plugins/splunk-on-call/src/plugin.ts @@ -24,9 +24,7 @@ import { createComponentExtension, } from '@backstage/core-plugin-api'; -export const rootRouteRef = createRouteRef({ - title: 'splunk-on-call', -}); +export const rootRouteRef = createRouteRef({ id: 'splunk-on-call' }); export const splunkOnCallPlugin = createPlugin({ id: 'splunk-on-call', diff --git a/plugins/tech-insights-backend-module-jsonfc/CHANGELOG.md b/plugins/tech-insights-backend-module-jsonfc/CHANGELOG.md new file mode 100644 index 0000000000..c168df5472 --- /dev/null +++ b/plugins/tech-insights-backend-module-jsonfc/CHANGELOG.md @@ -0,0 +1,20 @@ +# @backstage/plugin-tech-insights-backend-module-jsonfc + +## 0.1.2 + +### Patch Changes + +- c6c8b8e53e: Minor fixes in Readme to make the examples more directly usable. +- Updated dependencies + - @backstage/plugin-tech-insights-common@0.2.0 + - @backstage/backend-common@0.9.12 + - @backstage/plugin-tech-insights-node@0.1.1 + +## 0.1.1 + +### Patch Changes + +- 2017de90da: Update README docs to use correct function/parameter names +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/backend-common@0.9.11 diff --git a/plugins/tech-insights-backend-module-jsonfc/README.md b/plugins/tech-insights-backend-module-jsonfc/README.md index 48c1e69919..108b1c8564 100644 --- a/plugins/tech-insights-backend-module-jsonfc/README.md +++ b/plugins/tech-insights-backend-module-jsonfc/README.md @@ -24,7 +24,7 @@ and modify the `techInsights.ts` file to contain a reference to the FactCheckers + logger, +}), - const builder = new DefaultTechInsightsBuilder({ + const builder = buildTechInsightsContext({ logger, config, database, @@ -51,15 +51,17 @@ By default this implementation comes with an in-memory storage to store checks. Checks for this FactChecker are constructed as [`json-rules-engine` compatible JSON rules](https://github.com/CacheControl/json-rules-engine/blob/master/docs/rules.md#conditions). A check could look like the following for example: ```ts -import { TechInsightJsonRuleCheck } from '../types'; -import { JSON_RULE_ENGINE_CHECK_TYPE } from '../constants'; +import { + JSON_RULE_ENGINE_CHECK_TYPE, + TechInsightJsonRuleCheck, +} from '@backstage/plugin-tech-insights-backend-module-jsonfc'; export const exampleCheck: TechInsightJsonRuleCheck = { id: 'demodatacheck', // Unique identifier of this check name: 'demodatacheck', // A human readable name of this check to be displayed in the UI type: JSON_RULE_ENGINE_CHECK_TYPE, // Type identifier of the check. Used to run logic against, determine persistence option to use and render correct components on the UI description: 'A fact check for demoing purposes', // A description to be displayed in the UI - factRefs: ['demo-poc.factretriever'], // References to fact containers that this check uses. See documentation on FactRetrievers for more information on these + factIds: ['documentation-number-factretriever'], // References to fact ids that this check uses. See documentation on FactRetrievers for more information on these rule: { // The actual rule conditions: { diff --git a/plugins/tech-insights-backend-module-jsonfc/package.json b/plugins/tech-insights-backend-module-jsonfc/package.json index 8943b0e461..fa65ba561a 100644 --- a/plugins/tech-insights-backend-module-jsonfc/package.json +++ b/plugins/tech-insights-backend-module-jsonfc/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-insights-backend-module-jsonfc", - "version": "0.1.0", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,11 +31,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.0", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.8", - "@backstage/errors": "^0.1.1", - "@backstage/plugin-tech-insights-common": "^0.1.0", - "@backstage/plugin-tech-insights-node": "^0.1.0", + "@backstage/errors": "^0.1.5", + "@backstage/plugin-tech-insights-common": "^0.2.0", + "@backstage/plugin-tech-insights-node": "^0.1.1", "ajv": "^7.0.3", "json-rules-engine": "^6.1.2", "lodash": "^4.17.21", @@ -43,7 +43,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/node-cron": "^2.0.4" }, "files": [ diff --git a/plugins/tech-insights-backend/CHANGELOG.md b/plugins/tech-insights-backend/CHANGELOG.md new file mode 100644 index 0000000000..02267f1ee1 --- /dev/null +++ b/plugins/tech-insights-backend/CHANGELOG.md @@ -0,0 +1,36 @@ +# @backstage/plugin-tech-insights-backend + +## 0.1.3 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- b5bd60fddc: Removed unnecessary check for specific server error in `@backstage plugin-tech-insights-backend`. +- c6c8b8e53e: Minor fixes in Readme to make the examples more directly usable. +- Updated dependencies + - @backstage/plugin-tech-insights-common@0.2.0 + - @backstage/backend-common@0.9.12 + - @backstage/plugin-tech-insights-node@0.1.1 + +## 0.1.2 + +### Patch Changes + +- 2017de90da: Update README docs to use correct function/parameter names +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/backend-common@0.9.11 + +## 0.1.1 + +### Patch Changes + +- 5c00e45045: Add catalog fact retrievers + + Add fact retrievers which generate facts related to the completeness + of entity data in the catalog. + +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 diff --git a/plugins/tech-insights-backend/README.md b/plugins/tech-insights-backend/README.md index e2cc1c0252..31f5e402b0 100644 --- a/plugins/tech-insights-backend/README.md +++ b/plugins/tech-insights-backend/README.md @@ -22,7 +22,7 @@ do this by creating a file called `packages/backend/src/plugins/techInsights.ts` ```ts import { createRouter, - DefaultTechInsightsBuilder, + buildTechInsightsContext, } from '@backstage/plugin-tech-insights-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -33,7 +33,7 @@ export default async function createPlugin({ discovery, database, }: PluginEnvironment): Promise { - const builder = new DefaultTechInsightsBuilder({ + const builder = buildTechInsightsContext({ logger, config, database, @@ -42,7 +42,7 @@ export default async function createPlugin({ }); return await createRouter({ - ...(await builder.build()), + ...(await builder), logger, config, }); @@ -74,10 +74,10 @@ With the `techInsights.ts` router setup in place, add the router to At this point the Tech Insights backend is installed in your backend package, but you will not have any fact retrievers present in your application. To have the implemented FactRetrieverEngine within this package to be able to retrieve and store fact data into the database, you need to add these. -To create factRetrieverRegistration you need to implement `FactRetriever` interface defined in `@backstage/plugin-tech-insights-common` package. After you have implemented this interface you can wrap that into a registration object like follows: +To create factRetrieverRegistration you need to implement `FactRetriever` interface defined in `@backstage/plugin-tech-insights-node` package (see [Creating fact retrievers](#creating-fact-retrievers) for details). After you have implemented this interface you can wrap that into a registration object like follows: ```ts -import { createFactRetrieverRegistration } from './createFactRetriever'; +import { createFactRetrieverRegistration } from '@backstage/plugin-tech-insights-backend'; const myFactRetriever = { /** @@ -134,6 +134,8 @@ A Fact Retriever consist of four required and one optional parts: An example implementation of a FactRetriever could for example be as follows: ```ts +import { FactRetriever } from '@backstage/plugin-tech-insights-node'; + const myFactRetriever: FactRetriever = { id: 'documentation-number-factretriever', // unique identifier of the fact retriever version: '0.1.1', // SemVer version number of this fact retriever schema. This should be incremented if the implementation changes diff --git a/plugins/tech-insights-backend/api-report.md b/plugins/tech-insights-backend/api-report.md index 826789dd1d..ac2d1d1170 100644 --- a/plugins/tech-insights-backend/api-report.md +++ b/plugins/tech-insights-backend/api-report.md @@ -36,6 +36,12 @@ export function createRouter< CheckResultType extends CheckResult, >(options: RouterOptions): Promise; +// @public +export const entityMetadataFactRetriever: FactRetriever; + +// @public +export const entityOwnershipFactRetriever: FactRetriever; + // @public export type PersistenceContext = { techInsightsStore: TechInsightsStore; @@ -52,6 +58,9 @@ export interface RouterOptions< persistenceContext: PersistenceContext; } +// @public +export const techdocsFactRetriever: FactRetriever; + // @public (undocumented) export type TechInsightsContext< CheckType extends TechInsightCheck, diff --git a/plugins/tech-insights-backend/package.json b/plugins/tech-insights-backend/package.json index 678bec25c4..eb62d5b6a3 100644 --- a/plugins/tech-insights-backend/package.json +++ b/plugins/tech-insights-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-insights-backend", - "version": "0.1.0", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,15 +31,14 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.0", - "@backstage/catalog-client": "^0.3.16", - "@backstage/catalog-model": "^0.9.0", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.8", - "@backstage/errors": "^0.1.1", - "@backstage/plugin-tech-insights-common": "^0.1.0", - "@backstage/plugin-tech-insights-node": "^0.1.0", + "@backstage/errors": "^0.1.5", + "@backstage/plugin-tech-insights-common": "^0.2.0", + "@backstage/plugin-tech-insights-node": "^0.1.1", "@types/express": "^4.17.6", - "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", "knex": "^0.95.1", @@ -52,8 +51,8 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-test-utils": "^0.1.8", - "@backstage/cli": "^0.8.0", + "@backstage/backend-test-utils": "^0.1.10", + "@backstage/cli": "^0.10.0", "@types/supertest": "^2.0.8", "@types/node-cron": "^3.0.0", "@types/semver": "^7.3.8", diff --git a/plugins/tech-insights-backend/src/index.ts b/plugins/tech-insights-backend/src/index.ts index 5e8e690e8d..763c565108 100644 --- a/plugins/tech-insights-backend/src/index.ts +++ b/plugins/tech-insights-backend/src/index.ts @@ -25,3 +25,4 @@ export type { export type { PersistenceContext } from './service/persistence/persistenceContext'; export { createFactRetrieverRegistration } from './service/fact/createFactRetriever'; +export * from './service/fact/factRetrievers'; diff --git a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts index 4dc002465b..8d1f7180a3 100644 --- a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts +++ b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.test.ts @@ -44,7 +44,7 @@ const testFactRetriever: FactRetriever = { description: '', }, }, - handler: async () => { + handler: jest.fn(async () => { return [ { entity: { @@ -57,7 +57,7 @@ const testFactRetriever: FactRetriever = { }, }, ]; - }, + }), }; const cadence = '1 * * * *'; describe('FactRetrieverEngine', () => { @@ -145,5 +145,8 @@ describe('FactRetrieverEngine', () => { const job: any = engine.getJob('test-factretriever'); job.triggerScheduledJobNow(); expect(job.cadence!!).toEqual(cadence); + expect(testFactRetriever.handler).toHaveBeenCalledWith( + expect.objectContaining({ entityFilter: testFactRetriever.entityFilter }), + ); }); }); diff --git a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts index aa2207cca8..11fd0d29b8 100644 --- a/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts +++ b/plugins/tech-insights-backend/src/service/fact/FactRetrieverEngine.ts @@ -108,7 +108,10 @@ export class FactRetrieverEngine { this.logger.info( `Retrieving facts for fact retriever ${factRetriever.id}`, ); - const facts = await factRetriever.handler(this.factRetrieverContext); + const facts = await factRetriever.handler({ + ...this.factRetrieverContext, + entityFilter: factRetriever.entityFilter, + }); if (this.logger.isDebugEnabled()) { this.logger.debug( `Retrieved ${facts.length} facts for fact retriever ${ diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts new file mode 100644 index 0000000000..b99f6a1c02 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + PluginEndpointDiscovery, + getVoidLogger, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { CatalogListResponse } from '@backstage/catalog-client'; +import { entityMetadataFactRetriever } from './entityMetadataFactRetriever'; + +const getEntitiesMock = jest.fn(); +jest.mock('@backstage/catalog-client', () => { + return { + CatalogClient: jest + .fn() + .mockImplementation(() => ({ getEntities: getEntitiesMock })), + }; +}); +const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-owner', + description: 'service with an owner', + tags: ['a-tag'], + annotations: { + 'backstage.io/techdocs-ref': 'dir:.', + }, + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'team-a', + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { name: 'team-a', kind: 'group', namespace: 'default' }, + }, + ], + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-incomplete-data', + description: '', + tags: [], + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: '', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-user-owner', + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'user:my-user', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'user-a', + }, + kind: 'User', + spec: { + memberOf: 'group-a', + }, + }, + ], +}; + +const handlerContext = { + discovery, + logger: getVoidLogger(), + config: ConfigReader.fromConfigs([]), +}; + +const entityFactRetriever = entityMetadataFactRetriever; + +describe('entityMetadataFactRetriever', () => { + beforeEach(() => { + getEntitiesMock.mockResolvedValue(defaultEntityListResponse); + }); + afterEach(() => { + getEntitiesMock.mockClear(); + }); + + describe('hasDescription', () => { + describe('where the entity has a description', () => { + it('returns true for hasDescription', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasDescription: true, + }, + }); + }); + }); + describe('where the entity does not have a description', () => { + it('returns false for hasDescription', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect(facts.find(it => it.entity.name === 'user-a')).toMatchObject({ + facts: { + hasDescription: false, + }, + }); + }); + }); + describe('where the entity has an empty value for description', () => { + it('returns false for hasDescription', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasDescription: false, + }, + }); + }); + }); + }); + describe('hasTags', () => { + describe('where the entity has tags', () => { + it('returns true for hasTags', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasTags: true, + }, + }); + }); + }); + describe('where the entity has no tags', () => { + it('returns false for hasTags', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasTags: false, + }, + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts new file mode 100644 index 0000000000..535aede115 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityMetadataFactRetriever.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + FactRetriever, + FactRetrieverContext, +} from '@backstage/plugin-tech-insights-node'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import isEmpty from 'lodash/isEmpty'; + +/** + * Generates facts which indicate the completeness of entity metadata. + * + * @public + */ +export const entityMetadataFactRetriever: FactRetriever = { + id: 'entityMetadataFactRetriever', + version: '0.0.1', + schema: { + hasTitle: { + type: 'boolean', + description: 'The entity has a title in metadata', + }, + hasDescription: { + type: 'boolean', + description: 'The entity has a description in metadata', + }, + hasTags: { + type: 'boolean', + description: 'The entity has tags in metadata', + }, + }, + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + const entities = await catalogClient.getEntities({ filter: entityFilter }); + + return entities.items.map((entity: Entity) => { + return { + entity: { + namespace: entity.metadata.namespace!!, + kind: entity.kind, + name: entity.metadata.name, + }, + facts: { + hasTitle: Boolean(entity.metadata?.title), + hasDescription: Boolean(entity.metadata?.description), + hasTags: !isEmpty(entity.metadata?.tags), + }, + }; + }); + }, +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts new file mode 100644 index 0000000000..ea0801f17c --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.test.ts @@ -0,0 +1,194 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { entityOwnershipFactRetriever } from './entityOwnershipFactRetriever'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + PluginEndpointDiscovery, + getVoidLogger, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { CatalogListResponse } from '@backstage/catalog-client'; + +const getEntitiesMock = jest.fn(); +jest.mock('@backstage/catalog-client', () => { + return { + CatalogClient: jest + .fn() + .mockImplementation(() => ({ getEntities: getEntitiesMock })), + }; +}); +const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-owner', + description: 'service with an owner', + tags: ['a-tag'], + annotations: { + 'backstage.io/techdocs-ref': 'dir:.', + }, + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'team-a', + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { name: 'team-a', kind: 'group', namespace: 'default' }, + }, + ], + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-incomplete-data', + description: '', + tags: [], + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: '', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-user-owner', + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'user:my-user', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'user-a', + }, + kind: 'User', + spec: { + memberOf: 'group-a', + }, + }, + ], +}; + +const handlerContext = { + discovery, + logger: getVoidLogger(), + config: ConfigReader.fromConfigs([]), +}; + +const entityFactRetriever = entityOwnershipFactRetriever; + +describe('entityFactRetriever', () => { + beforeEach(() => { + getEntitiesMock.mockResolvedValue(defaultEntityListResponse); + }); + afterEach(() => { + getEntitiesMock.mockClear(); + }); + + describe('hasOwner', () => { + describe('where the entity has an owner in the spec', () => { + it('returns true for hasOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasOwner: true, + }, + }); + }); + }); + describe('where the entity has an empty value for owner in the spec', () => { + it('returns false for hasOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasOwner: false, + }, + }); + }); + }); + + describe('where the entity doesn not have an owner in the spec', () => { + it('returns false for hasOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect(facts.find(it => it.entity.name === 'user-a')).toMatchObject({ + facts: { + hasOwner: false, + }, + }); + }); + }); + }); + describe('hasGroupOwner', () => { + describe('where the entity has an group as owner in the spec', () => { + it('returns true for hasGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasGroupOwner: true, + }, + }); + }); + }); + describe('where the entity has a user as owner in the spec', () => { + it('returns false for hasGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-user-owner'), + ).toMatchObject({ + facts: { + hasGroupOwner: false, + }, + }); + }); + }); + describe('where the entity has an empty value for owner in the spec', () => { + it('returns false for hasGroupOwner', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasGroupOwner: false, + }, + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts new file mode 100644 index 0000000000..8f61f182ad --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/entityOwnershipFactRetriever.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + FactRetriever, + FactRetrieverContext, +} from '@backstage/plugin-tech-insights-node'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; + +/** + * Generates facts which indicate the quality of data in the spec.owner field. + * + * @public + */ +export const entityOwnershipFactRetriever: FactRetriever = { + id: 'entityOwnershipFactRetriever', + version: '0.0.1', + entityFilter: [ + { kind: ['component', 'domain', 'system', 'api', 'resource', 'template'] }, + ], + schema: { + hasOwner: { + type: 'boolean', + description: 'The spec.owner field is set', + }, + hasGroupOwner: { + type: 'boolean', + description: 'The spec.owner field is set and refers to a group', + }, + }, + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + const entities = await catalogClient.getEntities({ filter: entityFilter }); + + return entities.items.map((entity: Entity) => { + return { + entity: { + namespace: entity.metadata.namespace!!, + kind: entity.kind, + name: entity.metadata.name, + }, + facts: { + hasOwner: Boolean(entity.spec?.owner), + hasGroupOwner: Boolean( + entity.spec?.owner && + !(entity.spec?.owner as string).startsWith('user:'), + ), + }, + }; + }); + }, +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts new file mode 100644 index 0000000000..587db8c4b9 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './entityOwnershipFactRetriever'; +export * from './entityMetadataFactRetriever'; +export * from './techdocsFactRetriever'; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts new file mode 100644 index 0000000000..6ed9b12523 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.test.ts @@ -0,0 +1,147 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { techdocsFactRetriever } from './techdocsFactRetriever'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + PluginEndpointDiscovery, + getVoidLogger, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { CatalogListResponse } from '@backstage/catalog-client'; + +const getEntitiesMock = jest.fn(); +jest.mock('@backstage/catalog-client', () => { + return { + CatalogClient: jest + .fn() + .mockImplementation(() => ({ getEntities: getEntitiesMock })), + }; +}); +const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + +const defaultEntityListResponse: CatalogListResponse = { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-owner', + description: 'service with an owner', + tags: ['a-tag'], + annotations: { + 'backstage.io/techdocs-ref': 'dir:.', + }, + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'team-a', + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { name: 'team-a', kind: 'group', namespace: 'default' }, + }, + ], + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-incomplete-data', + description: '', + tags: [], + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: '', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'service-with-user-owner', + }, + kind: 'Component', + spec: { + type: 'service', + lifecycle: 'test', + owner: 'user:my-user', + }, + }, + { + apiVersion: 'backstage.io/v1beta1', + metadata: { + name: 'user-a', + }, + kind: 'User', + spec: { + memberOf: 'group-a', + }, + }, + ], +}; + +const handlerContext = { + discovery, + logger: getVoidLogger(), + config: ConfigReader.fromConfigs([]), +}; + +const entityFactRetriever = techdocsFactRetriever; + +describe('techdocsFactRetriever', () => { + beforeEach(() => { + getEntitiesMock.mockResolvedValue(defaultEntityListResponse); + }); + afterEach(() => { + getEntitiesMock.mockClear(); + }); + + describe('hasAnnotationBackstageIoTechdocsRef', () => { + describe('where the retriever is configured to check for the techdocs annotation', () => { + describe('where the entity has the techdocs annotation', () => { + it('returns true for hasAnnotationBackstageIoTechdocsRef', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-owner'), + ).toMatchObject({ + facts: { + hasAnnotationBackstageIoTechdocsRef: true, + }, + }); + }); + }); + describe('where the entity is missing the techdocs annotation', () => { + it('returns false for hasAnnotationBackstageIoTechdocsRef', async () => { + const facts = await entityFactRetriever.handler(handlerContext); + expect( + facts.find(it => it.entity.name === 'service-with-incomplete-data'), + ).toMatchObject({ + facts: { + hasAnnotationBackstageIoTechdocsRef: false, + }, + }); + }); + }); + }); + }); +}); diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts new file mode 100644 index 0000000000..c1c3ba5d20 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/techdocsFactRetriever.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + FactRetriever, + FactRetrieverContext, +} from '@backstage/plugin-tech-insights-node'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { entityHasAnnotation, generateAnnotationFactName } from './utils'; + +const techdocsAnnotation = 'backstage.io/techdocs-ref'; +const techdocsAnnotationFactName = + generateAnnotationFactName(techdocsAnnotation); + +/** + * Generates facts related to the completeness of techdocs configuration for entities. + * + * @public + */ +export const techdocsFactRetriever: FactRetriever = { + id: 'techdocsFactRetriever', + version: '0.0.1', + schema: { + [techdocsAnnotationFactName]: { + type: 'boolean', + description: 'The entity has a title in metadata', + }, + }, + handler: async ({ discovery, entityFilter }: FactRetrieverContext) => { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + const entities = await catalogClient.getEntities({ filter: entityFilter }); + + return entities.items.map((entity: Entity) => { + return { + entity: { + namespace: entity.metadata.namespace!!, + kind: entity.kind, + name: entity.metadata.name, + }, + facts: { + [techdocsAnnotationFactName]: entityHasAnnotation( + entity, + techdocsAnnotation, + ), + }, + }; + }); + }, +}; diff --git a/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts b/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts new file mode 100644 index 0000000000..7967502a63 --- /dev/null +++ b/plugins/tech-insights-backend/src/service/fact/factRetrievers/utils.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import camelCase from 'lodash/camelCase'; +import { Entity } from '@backstage/catalog-model'; +import { get } from 'lodash'; + +export const generateAnnotationFactName = (annotation: string) => + camelCase(`hasAnnotation-${annotation}`); + +export const entityHasAnnotation = (entity: Entity, annotation: string) => + Boolean(get(entity, ['metadata', 'annotations', annotation])); diff --git a/plugins/tech-insights-backend/src/service/router.ts b/plugins/tech-insights-backend/src/service/router.ts index a8951318ea..80902c307c 100644 --- a/plugins/tech-insights-backend/src/service/router.ts +++ b/plugins/tech-insights-backend/src/service/router.ts @@ -92,11 +92,6 @@ export async function createRouter< router.post('/checks/run/:namespace/:kind/:name', async (req, res) => { const { namespace, kind, name } = req.params; try { - if (!('checks' in req.body)) { - return res.status(422).send({ - message: 'Failed to get checks from request.', - }); - } const { checks }: { checks: string[] } = req.body; const entityTriplet = stringifyEntityRef({ namespace, kind, name }); const checkResult = await factChecker.runChecks(entityTriplet, checks); diff --git a/plugins/tech-insights-common/CHANGELOG.md b/plugins/tech-insights-common/CHANGELOG.md new file mode 100644 index 0000000000..9e72ce8ace --- /dev/null +++ b/plugins/tech-insights-common/CHANGELOG.md @@ -0,0 +1,7 @@ +# @backstage/plugin-tech-insights-common + +## 0.2.0 + +### Minor Changes + +- b5bd60fddc: Added new property 'result' in CheckResult in @backstage/plugin-tech-insights-common. This property is later used in `@backstage/plugin-tech-insights` package. diff --git a/plugins/tech-insights-common/api-report.md b/plugins/tech-insights-common/api-report.md index ddb38cfa85..8618f6266b 100644 --- a/plugins/tech-insights-common/api-report.md +++ b/plugins/tech-insights-common/api-report.md @@ -4,6 +4,7 @@ ```ts import { DateTime } from 'luxon'; +import { JsonValue } from '@backstage/types'; // @public export interface BooleanCheckResult extends CheckResult { @@ -25,6 +26,7 @@ export interface CheckResponse { export type CheckResult = { facts: FactResponse; check: CheckResponse; + result: JsonValue; }; // @public diff --git a/plugins/tech-insights-common/package.json b/plugins/tech-insights-common/package.json index 5e02c9e4b0..7bf668a24e 100644 --- a/plugins/tech-insights-common/package.json +++ b/plugins/tech-insights-common/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-insights-common", - "version": "0.1.0", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,10 +31,11 @@ }, "dependencies": { "@types/luxon": "^2.0.5", - "luxon": "^2.0.2" + "luxon": "^2.0.2", + "@backstage/types": "^0.1.1" }, "devDependencies": { - "@backstage/cli": "^0.8.0" + "@backstage/cli": "^0.10.0" }, "files": [ "dist" diff --git a/plugins/tech-insights-common/src/index.ts b/plugins/tech-insights-common/src/index.ts index 4bfc660748..ecdaa298c1 100644 --- a/plugins/tech-insights-common/src/index.ts +++ b/plugins/tech-insights-common/src/index.ts @@ -15,6 +15,7 @@ */ import { DateTime } from 'luxon'; +import { JsonValue } from '@backstage/types'; /** * @public @@ -112,6 +113,7 @@ export type FactResponse = { export type CheckResult = { facts: FactResponse; check: CheckResponse; + result: JsonValue; }; /** diff --git a/plugins/tech-insights-node/CHANGELOG.md b/plugins/tech-insights-node/CHANGELOG.md new file mode 100644 index 0000000000..83a42bb41e --- /dev/null +++ b/plugins/tech-insights-node/CHANGELOG.md @@ -0,0 +1,9 @@ +# @backstage/plugin-tech-insights-node + +## 0.1.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-tech-insights-common@0.2.0 + - @backstage/backend-common@0.9.12 diff --git a/plugins/tech-insights-node/api-report.md b/plugins/tech-insights-node/api-report.md index caf4b4cf0e..fc137da4bc 100644 --- a/plugins/tech-insights-node/api-report.md +++ b/plugins/tech-insights-node/api-report.md @@ -53,6 +53,9 @@ export type FactRetrieverContext = { config: Config; discovery: PluginEndpointDiscovery; logger: Logger_2; + entityFilter?: + | Record[] + | Record; }; // @public diff --git a/plugins/tech-insights-node/package.json b/plugins/tech-insights-node/package.json index 4a7cf3589b..5ba94a2ff8 100644 --- a/plugins/tech-insights-node/package.json +++ b/plugins/tech-insights-node/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-insights-node", - "version": "0.1.0", + "version": "0.1.1", "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.9.0", + "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.8", - "@backstage/plugin-tech-insights-common": "^0.1.0 ", + "@backstage/plugin-tech-insights-common": "^0.2.0", "@types/luxon": "^2.0.5", "luxon": "^2.0.2", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.0" + "@backstage/cli": "^0.10.0" }, "files": [ "dist" diff --git a/plugins/tech-insights-node/src/facts.ts b/plugins/tech-insights-node/src/facts.ts index a52a91f2f4..4a3157d391 100644 --- a/plugins/tech-insights-node/src/facts.ts +++ b/plugins/tech-insights-node/src/facts.ts @@ -136,6 +136,9 @@ export type FactRetrieverContext = { config: Config; discovery: PluginEndpointDiscovery; logger: Logger; + entityFilter?: + | Record[] + | Record; }; /** diff --git a/plugins/tech-insights/.eslintrc.js b/plugins/tech-insights/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/tech-insights/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/tech-insights/CHANGELOG.md b/plugins/tech-insights/CHANGELOG.md new file mode 100644 index 0000000000..d0c5e4fcdc --- /dev/null +++ b/plugins/tech-insights/CHANGELOG.md @@ -0,0 +1,15 @@ +# @backstage/plugin-tech-insights + +## 0.1.0 + +### Minor Changes + +- b5bd60fddc: New package containing UI components for the Tech Insights plugin. + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-tech-insights-common@0.2.0 + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 diff --git a/plugins/tech-insights/README.md b/plugins/tech-insights/README.md new file mode 100644 index 0000000000..b61cea48ec --- /dev/null +++ b/plugins/tech-insights/README.md @@ -0,0 +1,47 @@ +# Tech Insights + +This plugin provides the UI for the `@backstage/tech-insights-backend` plugin, in order to display results of the checks running following the rules and the logic defined in the `@backstage/tech-insights-backend` plugin itself. + +Main areas covered by this plugin currently are: + +- Providing an overview for default boolean checks in a form of Scorecards. + +- Providing an option to render different custom components based on type of the checks running in the backend. + +## Installation + +### Install the plugin + +```bash +# From your Backstage root directory +cd packages/app +yarn add @backstage/plugin-tech-insights +``` + +### Add boolean checks overview (Scorecards) page to the EntityPage: + +```tsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { EntityTechInsightsScorecardContent } from '@backstage/plugin-tech-insights'; + +const serviceEntityPage = ( + + + {overviewContent} + + + {cicdContent} + + ... + + + + ... + +); +``` + +## Links + +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/tech-insights/api-report.md b/plugins/tech-insights/api-report.md new file mode 100644 index 0000000000..e7f30a2b30 --- /dev/null +++ b/plugins/tech-insights/api-report.md @@ -0,0 +1,23 @@ +## API Report File for "@backstage/plugin-tech-insights" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// @public (undocumented) +export const EntityTechInsightsScorecardContent: () => JSX.Element; + +// @public (undocumented) +export const techInsightsPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {} +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/tech-insights/dev/index.tsx b/plugins/tech-insights/dev/index.tsx new file mode 100644 index 0000000000..17b1fca00e --- /dev/null +++ b/plugins/tech-insights/dev/index.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { + techInsightsPlugin, + EntityTechInsightsScorecardContent, +} from '../src/plugin'; + +createDevApp() + .registerPlugin(techInsightsPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/tech-insight-scorecard', + }) + .render(); diff --git a/plugins/tech-insights/package.json b/plugins/tech-insights/package.json new file mode 100644 index 0000000000..a55a0ce904 --- /dev/null +++ b/plugins/tech-insights/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/plugin-tech-insights", + "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" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-use": "^17.2.4", + "react-router-dom": "6.0.0-beta.0", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/plugin-tech-insights-common": "^0.2.0", + "@backstage/catalog-model": "^0.9.7", + "@backstage/errors": "^0.1.4", + "@backstage/types": "^0.1.1" + }, + "devDependencies": { + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "msw": "^0.35.0", + "cross-fetch": "^3.0.6" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/tech-insights/src/api/TechInsightsApi.ts b/plugins/tech-insights/src/api/TechInsightsApi.ts new file mode 100644 index 0000000000..2c55930f0a --- /dev/null +++ b/plugins/tech-insights/src/api/TechInsightsApi.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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-plugin-api'; +import { CheckResult } from '@backstage/plugin-tech-insights-common'; +import { Check } from './types'; +import { CheckResultRenderer } from '../components/CheckResultRenderer'; +import { EntityName } from '@backstage/catalog-model'; + +export const techInsightsApiRef = createApiRef({ + id: 'plugin.techinsights.service', + description: 'Used by the tech insights plugin to make requests', +}); + +export interface TechInsightsApi { + getScorecardsDefinition: ( + type: string, + value: CheckResult[], + ) => CheckResultRenderer | undefined; + getAllChecks(): Promise; + runChecks(entityParams: EntityName, checks?: Check[]): Promise; +} diff --git a/plugins/tech-insights/src/api/TechInsightsClient.ts b/plugins/tech-insights/src/api/TechInsightsClient.ts new file mode 100644 index 0000000000..25002e30a1 --- /dev/null +++ b/plugins/tech-insights/src/api/TechInsightsClient.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TechInsightsApi } from './TechInsightsApi'; +import { CheckResult } from '@backstage/plugin-tech-insights-common'; +import { Check } from './types'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { EntityName } from '@backstage/catalog-model'; + +import { + CheckResultRenderer, + defaultCheckResultRenderers, +} from '../components/CheckResultRenderer'; + +export type Options = { + discoveryApi: DiscoveryApi; +}; + +export class TechInsightsClient implements TechInsightsApi { + private readonly discoveryApi: DiscoveryApi; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + } + + getScorecardsDefinition( + type: string, + value: CheckResult[], + ): CheckResultRenderer | undefined { + const resultRenderers = defaultCheckResultRenderers(value); + return resultRenderers.find(d => d.type === type); + } + + async getAllChecks(): Promise { + const url = await this.discoveryApi.getBaseUrl('tech-insights'); + const response = await fetch(`${url}/checks`); + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + return await response.json(); + } + + async runChecks( + entityParams: EntityName, + checks: Check[], + ): Promise { + const url = await this.discoveryApi.getBaseUrl('tech-insights'); + const { namespace, kind, name } = entityParams; + const allChecks = checks ? checks : await this.getAllChecks(); + const checkIds = allChecks.map((check: Check) => check.id); + const response = await fetch( + `${url}/checks/run/${encodeURIComponent(namespace)}/${encodeURIComponent( + kind, + )}/${encodeURIComponent(name)}`, + { + method: 'POST', + body: JSON.stringify({ checks: checkIds }), + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + return await response.json(); + } +} diff --git a/plugins/tech-insights/src/api/index.ts b/plugins/tech-insights/src/api/index.ts new file mode 100644 index 0000000000..bcd2575a52 --- /dev/null +++ b/plugins/tech-insights/src/api/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './TechInsightsApi'; +export * from './TechInsightsClient'; diff --git a/plugins/tech-insights/src/api/types.ts b/plugins/tech-insights/src/api/types.ts new file mode 100644 index 0000000000..20071ba0c9 --- /dev/null +++ b/plugins/tech-insights/src/api/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 Check = { + id: string; + type: string; + name: string; + description: string; + factIds: string[]; +}; diff --git a/plugins/tech-insights/src/components/BooleanCheck/BooleanCheck.tsx b/plugins/tech-insights/src/components/BooleanCheck/BooleanCheck.tsx new file mode 100644 index 0000000000..e38e0aa7b3 --- /dev/null +++ b/plugins/tech-insights/src/components/BooleanCheck/BooleanCheck.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles, List, ListItem, ListItemText } from '@material-ui/core'; +import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; +import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'; +import { CheckResult } from '@backstage/plugin-tech-insights-common'; +import { BackstageTheme } from '@backstage/theme'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + listItemText: { + paddingRight: theme.spacing(0.5), + flex: '0 1 auto', + }, + icon: { + marginLeft: 'auto', + }, +})); + +type Prop = { + checkResult: CheckResult[]; +}; + +export const BooleanCheck = ({ checkResult }: Prop) => { + const classes = useStyles(); + + return ( + + {checkResult.map((check, index) => ( + + + {check.result ? ( + + ) : ( + + )} + + ))} + + ); +}; diff --git a/plugins/tech-insights/src/components/BooleanCheck/index.ts b/plugins/tech-insights/src/components/BooleanCheck/index.ts new file mode 100644 index 0000000000..729ab4d62e --- /dev/null +++ b/plugins/tech-insights/src/components/BooleanCheck/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { BooleanCheck } from './BooleanCheck'; diff --git a/plugins/tech-insights/src/components/CheckResultRenderer.tsx b/plugins/tech-insights/src/components/CheckResultRenderer.tsx new file mode 100644 index 0000000000..60e0fad9c5 --- /dev/null +++ b/plugins/tech-insights/src/components/CheckResultRenderer.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CheckResult } from '@backstage/plugin-tech-insights-common'; +import React from 'react'; +import { BooleanCheck } from './BooleanCheck'; + +export type CheckResultRenderer = { + type: string; + title: string; + description: string; + component: React.ReactElement; +}; + +export function defaultCheckResultRenderers( + value: CheckResult[], +): CheckResultRenderer[] { + return [ + { + type: 'json-rules-engine', + title: 'Boolean scorecard', + description: + 'This card represents an overview of default boolean Backstage checks:', + component: , + }, + ]; +} diff --git a/plugins/tech-insights/src/components/ScorecardsOverview/ChecksOverview.tsx b/plugins/tech-insights/src/components/ScorecardsOverview/ChecksOverview.tsx new file mode 100644 index 0000000000..10025d1ba6 --- /dev/null +++ b/plugins/tech-insights/src/components/ScorecardsOverview/ChecksOverview.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles, Grid, Typography } from '@material-ui/core'; +import { useApi } from '@backstage/core-plugin-api'; +import { Content, Page, InfoCard } from '@backstage/core-components'; +import { CheckResult } from '@backstage/plugin-tech-insights-common'; +import { techInsightsApiRef } from '../../api/TechInsightsApi'; +import { BackstageTheme } from '@backstage/theme'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + contentScorecards: { + paddingLeft: 0, + paddingRight: 0, + }, + subheader: { + fontWeight: 'bold', + paddingLeft: theme.spacing(0.5), + }, +})); + +type Checks = { + checks: CheckResult[]; +}; + +export const ChecksOverview = ({ checks }: Checks) => { + const classes = useStyles(); + const api = useApi(techInsightsApiRef); + const checkRenderType = api.getScorecardsDefinition( + checks[0].check.type, + checks, + ); + + if (checkRenderType) { + return ( + + + + + + {checkRenderType.description} + + + {checkRenderType.component} + + + + + + ); + } + + return <>; +}; diff --git a/plugins/tech-insights/src/components/ScorecardsOverview/ScorecardsOverview.tsx b/plugins/tech-insights/src/components/ScorecardsOverview/ScorecardsOverview.tsx new file mode 100644 index 0000000000..92a57bd643 --- /dev/null +++ b/plugins/tech-insights/src/components/ScorecardsOverview/ScorecardsOverview.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { useParams } from 'react-router-dom'; +import { useAsync } from 'react-use'; +import { Progress } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { ChecksOverview } from './ChecksOverview'; +import Alert from '@material-ui/lab/Alert'; +import { techInsightsApiRef } from '../../api/TechInsightsApi'; + +export const ScorecardsOverview = () => { + const api = useApi(techInsightsApiRef); + const { namespace, kind, name } = useParams(); + + const { value, loading, error } = useAsync( + async () => await api.runChecks({ namespace, kind, name }), + ); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + return ; +}; diff --git a/plugins/tech-insights/src/components/ScorecardsOverview/index.ts b/plugins/tech-insights/src/components/ScorecardsOverview/index.ts new file mode 100644 index 0000000000..64198790d9 --- /dev/null +++ b/plugins/tech-insights/src/components/ScorecardsOverview/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { ScorecardsOverview } from './ScorecardsOverview'; diff --git a/plugins/tech-insights/src/index.ts b/plugins/tech-insights/src/index.ts new file mode 100644 index 0000000000..273c11fd71 --- /dev/null +++ b/plugins/tech-insights/src/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + techInsightsPlugin, + EntityTechInsightsScorecardContent, +} from './plugin'; diff --git a/plugins/tech-insights/src/plugin.test.ts b/plugins/tech-insights/src/plugin.test.ts new file mode 100644 index 0000000000..a4a9e6f308 --- /dev/null +++ b/plugins/tech-insights/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { techInsightsPlugin } from './plugin'; + +describe('tech insights', () => { + it('should export plugin', () => { + expect(techInsightsPlugin).toBeDefined(); + }); +}); diff --git a/plugins/tech-insights/src/plugin.ts b/plugins/tech-insights/src/plugin.ts new file mode 100644 index 0000000000..f229b654f3 --- /dev/null +++ b/plugins/tech-insights/src/plugin.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + createPlugin, + createRoutableExtension, + createApiFactory, + discoveryApiRef, +} from '@backstage/core-plugin-api'; +import { rootRouteRef } from './routes'; +import { techInsightsApiRef } from './api/TechInsightsApi'; +import { TechInsightsClient } from './api/TechInsightsClient'; + +/** + * @public + */ +export const techInsightsPlugin = createPlugin({ + id: 'tech-insights', + apis: [ + createApiFactory({ + api: techInsightsApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new TechInsightsClient({ discoveryApi }), + }), + ], + routes: { + root: rootRouteRef, + }, +}); + +/** + * @public + */ +export const EntityTechInsightsScorecardContent = techInsightsPlugin.provide( + createRoutableExtension({ + name: 'EntityTechInsightsScorecardContent', + component: () => + import('./components/ScorecardsOverview').then(m => m.ScorecardsOverview), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/tech-insights/src/routes.ts b/plugins/tech-insights/src/routes.ts new file mode 100644 index 0000000000..b1b7900074 --- /dev/null +++ b/plugins/tech-insights/src/routes.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'tech-insights', +}); diff --git a/plugins/tech-insights/src/setupTests.ts b/plugins/tech-insights/src/setupTests.ts new file mode 100644 index 0000000000..fc6dbd98f8 --- /dev/null +++ b/plugins/tech-insights/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index 8e7b5875a9..cef7885007 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-tech-radar +## 0.4.12 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.4.11 ### Patch Changes diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 839ad1b399..d377318e4b 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-tech-radar", "description": "A Backstage plugin that lets you display a Tech Radar for your organization", - "version": "0.4.11", + "version": "0.4.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -45,10 +45,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/tech-radar/src/components/RadarComponent.test.tsx b/plugins/tech-radar/src/components/RadarComponent.test.tsx index cce411e897..2b237c8d83 100644 --- a/plugins/tech-radar/src/components/RadarComponent.test.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.test.tsx @@ -19,13 +19,12 @@ import { render, waitForElement } from '@testing-library/react'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { act } from 'react-dom/test-utils'; -import { withLogCollector } from '@backstage/test-utils'; +import { TestApiProvider, withLogCollector } from '@backstage/test-utils'; import GetBBoxPolyfill from '../utils/polyfills/getBBox'; import { RadarComponent } from './RadarComponent'; import { TechRadarLoaderResponse, techRadarApiRef, TechRadarApi } from '../api'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; describe('RadarComponent', () => { @@ -55,18 +54,18 @@ describe('RadarComponent', () => { const errorApi = { post: () => {} }; const { getByTestId, queryByTestId } = render( - - + , ); @@ -87,18 +86,18 @@ describe('RadarComponent', () => { const { queryByTestId } = render( - - + , ); @@ -115,13 +114,13 @@ describe('RadarComponent', () => { expect(() => { render( - + - + , ); }).toThrow(); diff --git a/plugins/tech-radar/src/components/RadarPage.test.tsx b/plugins/tech-radar/src/components/RadarPage.test.tsx index 7215fc40df..7f4e69e388 100644 --- a/plugins/tech-radar/src/components/RadarPage.test.tsx +++ b/plugins/tech-radar/src/components/RadarPage.test.tsx @@ -17,6 +17,7 @@ import { MockErrorApi, renderInTestApp, + TestApiProvider, wrapInTestApp, } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; @@ -28,7 +29,6 @@ import GetBBoxPolyfill from '../utils/polyfills/getBBox'; import { RadarPage } from './RadarPage'; import { TechRadarLoaderResponse, techRadarApiRef, TechRadarApi } from '../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; describe('RadarPage', () => { @@ -63,9 +63,9 @@ describe('RadarPage', () => { const { getByTestId, queryByTestId } = render( wrapInTestApp( - + - + , ), ); @@ -89,9 +89,9 @@ describe('RadarPage', () => { const { getByText, getByTestId } = await renderInTestApp( - + - + , ); @@ -115,9 +115,9 @@ describe('RadarPage', () => { const { getByTestId } = await renderInTestApp( - + - + , ); @@ -142,14 +142,14 @@ describe('RadarPage', () => { const { queryByTestId } = await renderInTestApp( - - + , ); diff --git a/plugins/tech-radar/src/plugin.ts b/plugins/tech-radar/src/plugin.ts index bd30d6a984..495aa47879 100644 --- a/plugins/tech-radar/src/plugin.ts +++ b/plugins/tech-radar/src/plugin.ts @@ -25,7 +25,7 @@ import { } from '@backstage/core-plugin-api'; const rootRouteRef = createRouteRef({ - title: 'Tech Radar', + id: 'tech-radar', }); /** diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index 7331ea0147..099049a896 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,76 @@ # @backstage/plugin-techdocs-backend +## 0.11.0 + +### Minor Changes + +- 905dd952ac: **BREAKING** `DefaultTechDocsCollator` has a new required option `tokenManager`. See the create-app changelog for how to create a `tokenManager` and add it to the `PluginEnvironment`. It can then be passed to the collator in `createPlugin`: + + ```diff + // packages/backend/src/plugins/search.ts + + ... + export default async function createPlugin({ + ... + + tokenManager, + }: PluginEnvironment) { + ... + + indexBuilder.addCollator({ + defaultRefreshIntervalSeconds: 600, + collator: DefaultTechDocsCollator.fromConfig(config, { + discovery, + logger, + + tokenManager, + }), + }); + + ... + } + ``` + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/backend-common@0.9.12 + +## 0.10.9 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/backend-common@0.9.11 + - @backstage/techdocs-common@0.10.8 + +## 0.10.8 + +### Patch Changes + +- e21e3c6102: Bumping minimum requirements for `dockerode` and `testcontainers` +- 9e64a7ac1e: Allow amazon web services s3 buckets to pass an server side encryption configuration so they can publish to encrypted buckets +- Updated dependencies + - @backstage/catalog-client@0.5.2 + - @backstage/catalog-model@0.9.7 + - @backstage/backend-common@0.9.10 + - @backstage/techdocs-common@0.10.7 + ## 0.10.7 ### Patch Changes diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md index a6094c2bdd..f9be757e6a 100644 --- a/plugins/techdocs-backend/api-report.md +++ b/plugins/techdocs-backend/api-report.md @@ -14,6 +14,7 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PreparerBuilder } from '@backstage/techdocs-common'; import { PublisherBase } from '@backstage/techdocs-common'; import { TechDocsDocument } from '@backstage/techdocs-common'; +import { TokenManager } from '@backstage/backend-common'; // Warning: (ae-forgotten-export) The symbol "RouterOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -31,6 +32,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { locationTemplate, logger, catalogClient, + tokenManager, parallelismLimit, legacyPathCasing, }: TechDocsCollatorOptions); @@ -60,6 +62,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { export type TechDocsCollatorOptions = { discovery: PluginEndpointDiscovery; logger: Logger_2; + tokenManager: TokenManager; locationTemplate?: string; catalogClient?: CatalogApi; parallelismLimit?: number; diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index da3cd34830..9e563137bd 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -227,14 +227,14 @@ export interface Config { }; /** - * @example http://localhost:7000/api/techdocs + * @example http://localhost:7007/api/techdocs * @visibility frontend * @deprecated */ requestUrl?: string; /** - * @example http://localhost:7000/api/techdocs/static/docs + * @example http://localhost:7007/api/techdocs/static/docs * @deprecated */ storageUrl?: string; diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index c71833948b..67ff25166b 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-techdocs-backend", "description": "The Backstage backend plugin that renders technical documentation for your components", - "version": "0.10.7", + "version": "0.11.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,29 +31,29 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.8", - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.6", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", + "@backstage/errors": "^0.1.5", + "@backstage/integration": "^0.6.10", "@backstage/search-common": "^0.2.1", - "@backstage/techdocs-common": "^0.10.5", + "@backstage/techdocs-common": "^0.10.8", "@types/express": "^4.17.6", - "cross-fetch": "^3.0.6", - "dockerode": "^3.2.1", + "dockerode": "^3.3.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", "fs-extra": "9.1.0", "knex": "^0.95.1", "lodash": "^4.17.21", + "node-fetch": "^2.6.1", "p-limit": "^3.1.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.8.1", - "@backstage/test-utils": "^0.1.20", - "@types/dockerode": "^3.2.1", + "@backstage/cli": "^0.10.0", + "@backstage/test-utils": "^0.1.23", + "@types/dockerode": "^3.3.0", "msw": "^0.35.0", "supertest": "^6.1.3" }, diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts index 21be228636..5f0bed55dd 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts @@ -17,6 +17,7 @@ import { PluginEndpointDiscovery, getVoidLogger, + TokenManager, } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { DefaultTechDocsCollator } from './DefaultTechDocsCollator'; @@ -87,6 +88,7 @@ const expectedEntities: Entity[] = [ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { let mockDiscoveryApi: jest.Mocked; + let mockTokenManager: jest.Mocked; let collator: DefaultTechDocsCollator; const worker = setupServer(); @@ -96,6 +98,10 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'), getExternalBaseUrl: jest.fn(), }; + mockTokenManager = { + getToken: jest.fn().mockResolvedValue({ token: '' }), + authenticate: jest.fn(), + }; const mockConfig = new ConfigReader({ techdocs: { legacyUseCaseSensitiveTripletPaths: true, @@ -103,6 +109,7 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { }); collator = DefaultTechDocsCollator.fromConfig(mockConfig, { discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, logger, legacyPathCasing: true, }); @@ -147,6 +154,7 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { describe('DefaultTechDocsCollator', () => { let mockDiscoveryApi: jest.Mocked; + let mockTokenManager: jest.Mocked; let collator: DefaultTechDocsCollator; const worker = setupServer(); @@ -156,8 +164,13 @@ describe('DefaultTechDocsCollator', () => { getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'), getExternalBaseUrl: jest.fn(), }; + mockTokenManager = { + getToken: jest.fn().mockResolvedValue({ token: '' }), + authenticate: jest.fn(), + }; collator = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), { discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, logger, }); @@ -195,6 +208,7 @@ describe('DefaultTechDocsCollator', () => { // Provide an alternate location template. collator = new DefaultTechDocsCollator({ discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, locationTemplate: '/software/:name', logger, }); diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts index 121ef6e46d..283d0264e4 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts @@ -14,10 +14,13 @@ * limitations under the License. */ -import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { + PluginEndpointDiscovery, + TokenManager, +} from '@backstage/backend-common'; import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; import { DocumentCollator } from '@backstage/search-common'; -import fetch from 'cross-fetch'; +import fetch from 'node-fetch'; import unescape from 'lodash/unescape'; import { Logger } from 'winston'; import pLimit from 'p-limit'; @@ -34,6 +37,7 @@ interface MkSearchIndexDoc { export type TechDocsCollatorOptions = { discovery: PluginEndpointDiscovery; logger: Logger; + tokenManager: TokenManager; locationTemplate?: string; catalogClient?: CatalogApi; parallelismLimit?: number; @@ -51,6 +55,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { protected locationTemplate: string; private readonly logger: Logger; private readonly catalogClient: CatalogApi; + private readonly tokenManager: TokenManager; private readonly parallelismLimit: number; private readonly legacyPathCasing: boolean; public readonly type: string = 'techdocs'; @@ -63,6 +68,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { locationTemplate, logger, catalogClient, + tokenManager, parallelismLimit = 10, legacyPathCasing = false, }: TechDocsCollatorOptions) { @@ -74,6 +80,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { catalogClient || new CatalogClient({ discoveryApi: discovery }); this.parallelismLimit = parallelismLimit; this.legacyPathCasing = legacyPathCasing; + this.tokenManager = tokenManager; } static fromConfig(config: Config, options: TechDocsCollatorOptions) { @@ -87,19 +94,23 @@ export class DefaultTechDocsCollator implements DocumentCollator { async execute() { const limit = pLimit(this.parallelismLimit); const techDocsBaseUrl = await this.discovery.getBaseUrl('techdocs'); - const entities = await this.catalogClient.getEntities({ - fields: [ - 'kind', - 'namespace', - 'metadata.annotations', - 'metadata.name', - 'metadata.title', - 'metadata.namespace', - 'spec.type', - 'spec.lifecycle', - 'relations', - ], - }); + const { token } = await this.tokenManager.getToken(); + const entities = await this.catalogClient.getEntities( + { + fields: [ + 'kind', + 'namespace', + 'metadata.annotations', + 'metadata.name', + 'metadata.title', + 'metadata.namespace', + 'spec.type', + 'spec.lifecycle', + 'relations', + ], + }, + { token }, + ); const docPromises = entities.items .filter(it => it.metadata?.annotations?.['backstage.io/techdocs-ref']) .map((entity: Entity) => diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index e560a895b6..d53a2faed4 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -24,7 +24,7 @@ import { PreparerBuilder, PublisherBase, } from '@backstage/techdocs-common'; -import fetch from 'cross-fetch'; +import fetch from 'node-fetch'; import express, { Response } from 'express'; import Router from 'express-promise-router'; import { Knex } from 'knex'; diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index be100b9dea..22501e0ad7 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,56 @@ # @backstage/plugin-techdocs +## 0.12.8 + +### Patch Changes + +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + - @backstage/plugin-search@0.5.0 + +## 0.12.7 + +### Patch Changes + +- bab752e2b3: Change default port of backend from 7000 to 7007. + + This is due to the AirPlay Receiver process occupying port 7000 and preventing local Backstage instances on MacOS to start. + + You can change the port back to 7000 or any other value by providing an `app-config.yaml` with the following values: + + ``` + backend: + listen: 0.0.0.0:7123 + baseUrl: http://localhost:7123 + ``` + + More information can be found here: https://backstage.io/docs/conf/writing + +- Updated dependencies + - @backstage/errors@0.1.5 + - @backstage/core-plugin-api@0.2.1 + - @backstage/core-components@0.7.5 + +## 0.12.6 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- c1858c4cf9: Fixed entity triplet case handling for certain locales. +- f7703981a9: Use a better checkbox rendering in a task list. +- e266687580: Updates reader component used to display techdocs documentation. A previous change made this component not usable out of a page which don't have entityRef in url parameters. Reader component EntityRef parameter is now used instead of url parameters. Techdocs documentation component can now be used in our custom pages. +- Updated dependencies + - @backstage/plugin-catalog@0.7.3 + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/plugin-search@0.4.18 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + - @backstage/integration-react@0.1.14 + ## 0.12.5 ### Patch Changes diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index d6bf21b10b..45fd524eff 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -34,7 +34,7 @@ export interface Config { legacyUseCaseSensitiveTripletPaths?: boolean; /** - * @example http://localhost:7000/api/techdocs + * @example http://localhost:7007/api/techdocs * @visibility frontend * @deprecated */ diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 79841a0621..c65fba7210 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-techdocs", "description": "The Backstage plugin that renders technical documentation for your components", - "version": "0.12.5", + "version": "0.12.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,17 +32,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.6", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.11", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/errors": "^0.1.4", - "@backstage/integration": "^0.6.9", - "@backstage/integration-react": "^0.1.13", - "@backstage/plugin-catalog": "^0.7.2", - "@backstage/plugin-catalog-react": "^0.6.3", - "@backstage/plugin-search": "^0.4.17", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/errors": "^0.1.5", + "@backstage/integration": "^0.6.10", + "@backstage/integration-react": "^0.1.14", + "@backstage/plugin-catalog": "^0.7.3", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/plugin-search": "^0.5.0", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -61,10 +61,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^7.0.2", diff --git a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx index 1d8d7d2b04..8618c4432f 100644 --- a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx +++ b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx @@ -14,11 +14,7 @@ * limitations under the License. */ -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { ConfigApi, configApiRef, @@ -30,7 +26,11 @@ import { DefaultStarredEntitiesApi, starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; -import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import { + MockStorageApi, + renderInTestApp, + TestApiRegistry, +} from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; import { rootDocsRouteRef } from '../../routes'; @@ -69,12 +69,12 @@ describe('TechDocs Home', () => { const storageApi = MockStorageApi.create(); - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [catalogApiRef, mockCatalogApi], [configApiRef, configApi], [storageApiRef, storageApi], [starredEntitiesApiRef, new DefaultStarredEntitiesApi({ storageApi })], - ]); + ); it('should render a TechDocs home page', async () => { await renderInTestApp( diff --git a/plugins/techdocs/src/home/components/LegacyTechDocsHome.test.tsx b/plugins/techdocs/src/home/components/LegacyTechDocsHome.test.tsx index d9d2c6a345..a8fd472f7c 100644 --- a/plugins/techdocs/src/home/components/LegacyTechDocsHome.test.tsx +++ b/plugins/techdocs/src/home/components/LegacyTechDocsHome.test.tsx @@ -15,16 +15,12 @@ */ import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; import { LegacyTechDocsHome } from './LegacyTechDocsHome'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { ConfigApi, configApiRef } from '@backstage/core-plugin-api'; import { rootDocsRouteRef } from '../../routes'; @@ -59,10 +55,10 @@ describe('Legacy TechDocs Home', () => { }, }); - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [catalogApiRef, mockCatalogApi], [configApiRef, configApi], - ]); + ); it('should render a TechDocs home page', async () => { await renderInTestApp( diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx index bcbfdeed86..e9c3a55b15 100644 --- a/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx @@ -15,11 +15,11 @@ */ import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; import { TechDocsCustomHome, PanelType } from './TechDocsCustomHome'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { rootDocsRouteRef } from '../../routes'; jest.mock('@backstage/plugin-catalog-react', () => { @@ -47,7 +47,7 @@ const mockCatalogApi = { } as Partial; describe('TechDocsCustomHome', () => { - const apiRegistry = ApiRegistry.with(catalogApiRef, mockCatalogApi); + const apiRegistry = TestApiRegistry.from([catalogApiRef, mockCatalogApi]); it('should render a TechDocs home page', async () => { const tabsConfig = [ diff --git a/plugins/techdocs/src/reader/components/Reader.test.tsx b/plugins/techdocs/src/reader/components/Reader.test.tsx index 8b5dbced3f..6e8609694d 100644 --- a/plugins/techdocs/src/reader/components/Reader.test.tsx +++ b/plugins/techdocs/src/reader/components/Reader.test.tsx @@ -19,12 +19,12 @@ import { ScmIntegrationsApi, scmIntegrationsApiRef, } from '@backstage/integration-react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { act, render } from '@testing-library/react'; import React from 'react'; import { TechDocsStorageApi, techdocsStorageApiRef } from '../../api'; import { Reader } from './Reader'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { searchApiRef } from '@backstage/plugin-search'; jest.mock('react-router-dom', () => { @@ -57,11 +57,11 @@ describe('', () => { results: [], }), }; - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [scmIntegrationsApiRef, scmIntegrationsApi], [techdocsStorageApiRef, techdocsStorageApi], [searchApiRef, searchApi], - ]); + ); await act(async () => { const rendered = render( diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx index 6a5af982fd..b297466a62 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx @@ -21,7 +21,7 @@ import { ScmIntegrationsApi, scmIntegrationsApiRef, } from '@backstage/integration-react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { Header } from '@backstage/core-components'; import { techdocsApiRef, @@ -29,7 +29,7 @@ import { techdocsStorageApiRef, TechDocsStorageApi, } from '../../api'; -import { ApiRegistry, ApiProvider } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { searchApiRef } from '@backstage/plugin-search'; jest.mock('react-router-dom', () => { @@ -90,12 +90,12 @@ describe('', () => { results: [], }), }; - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [scmIntegrationsApiRef, scmIntegrationsApi], [techdocsApiRef, techdocsApi], [techdocsStorageApiRef, techdocsStorageApi], [searchApiRef, searchApi], - ]); + ); await act(async () => { const rendered = render( @@ -147,12 +147,12 @@ describe('', () => { results: [], }), }; - const apiRegistry = ApiRegistry.from([ + const apiRegistry = TestApiRegistry.from( [scmIntegrationsApiRef, scmIntegrationsApi], [techdocsApiRef, techdocsApi], [techdocsStorageApiRef, techdocsStorageApi], [searchApiRef, searchApi], - ]); + ); await act(async () => { const rendered = render( diff --git a/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx b/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx index 602a94e758..5b3d714a04 100644 --- a/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsSearch.test.tsx @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { ApiProvider } from '@backstage/core-app-api'; import { searchApiRef } from '@backstage/plugin-search'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { act, fireEvent, @@ -54,7 +54,7 @@ describe('', () => { const querySpy = jest.fn(query); const searchApi = { query: querySpy }; - const apiRegistry = ApiRegistry.from([[searchApiRef, searchApi]]); + const apiRegistry = TestApiRegistry.from([searchApiRef, searchApi]); await act(async () => { const rendered = render( @@ -75,7 +75,7 @@ describe('', () => { const querySpy = jest.fn(query); const searchApi = { query: querySpy }; - const apiRegistry = ApiRegistry.from([[searchApiRef, searchApi]]); + const apiRegistry = TestApiRegistry.from([searchApiRef, searchApi]); await act(async () => { const rendered = render( diff --git a/plugins/techdocs/src/reader/components/useReaderState.test.tsx b/plugins/techdocs/src/reader/components/useReaderState.test.tsx index cce8282b3d..e41a114dbe 100644 --- a/plugins/techdocs/src/reader/components/useReaderState.test.tsx +++ b/plugins/techdocs/src/reader/components/useReaderState.test.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { NotFoundError } from '@backstage/errors'; +import { TestApiProvider } from '@backstage/test-utils'; import { act, renderHook } from '@testing-library/react-hooks'; import React from 'react'; import { techdocsStorageApiRef } from '../../api'; @@ -38,10 +38,10 @@ describe('useReaderState', () => { }; beforeEach(() => { - const apis = ApiRegistry.with(techdocsStorageApiRef, techdocsStorageApi); - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - {children} + + {children} + ); }); diff --git a/plugins/techdocs/src/routes.ts b/plugins/techdocs/src/routes.ts index e0b7661ffd..e781550899 100644 --- a/plugins/techdocs/src/routes.ts +++ b/plugins/techdocs/src/routes.ts @@ -17,17 +17,14 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - id: 'techdocs-index-page', - title: 'TechDocs Landing Page', + id: 'techdocs:index-page', }); export const rootDocsRouteRef = createRouteRef({ - id: 'techdocs-reader-page', - title: 'Docs', + id: 'techdocs:reader-page', params: ['namespace', 'kind', 'name'], }); export const rootCatalogDocsRouteRef = createRouteRef({ - id: 'catalog-techdocs-reader-view', - title: 'Docs', + id: 'techdocs:catalog-reader-view', }); diff --git a/plugins/todo-backend/CHANGELOG.md b/plugins/todo-backend/CHANGELOG.md index 60fd7a5490..f27dba351d 100644 --- a/plugins/todo-backend/CHANGELOG.md +++ b/plugins/todo-backend/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-todo-backend +## 0.1.14 + +### Patch Changes + +- b055a6addc: Align on usage of `cross-fetch` vs `node-fetch` in frontend vs backend packages, and remove some unnecessary imports of either one of them +- Updated dependencies + - @backstage/integration@0.6.10 + - @backstage/backend-common@0.9.12 + ## 0.1.13 ### Patch Changes diff --git a/plugins/todo-backend/README.md b/plugins/todo-backend/README.md index 61ca5202d0..5540ca598e 100644 --- a/plugins/todo-backend/README.md +++ b/plugins/todo-backend/README.md @@ -36,6 +36,19 @@ export default async function createPlugin({ } ``` +And then add to `packages/backend/src/index.ts`: + +```js +// In packages/backend/src/index.ts +import todo from './plugins/todo'; +// ... +async function main() { + // ... + const todoEnv = useHotMemoize(module, () => createEnv('todo')); + // ... + apiRouter.use('/todo', await todo(todoEnv)); +``` + ## 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` diff --git a/plugins/todo-backend/package.json b/plugins/todo-backend/package.json index c3f0e469aa..dd69f60072 100644 --- a/plugins/todo-backend/package.json +++ b/plugins/todo-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-todo-backend", "description": "A Backstage backend plugin that lets you browse TODO comments in your source code", - "version": "0.1.13", + "version": "0.1.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -25,14 +25,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.7", - "@backstage/catalog-client": "^0.5.0", - "@backstage/catalog-model": "^0.9.5", + "@backstage/backend-common": "^0.9.12", + "@backstage/catalog-client": "^0.5.2", + "@backstage/catalog-model": "^0.9.7", "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.3", - "@backstage/integration": "^0.6.7", + "@backstage/integration": "^0.6.10", "@types/express": "^4.17.6", - "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", "leasot": "^12.0.0", @@ -40,7 +39,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.8.0", + "@backstage/cli": "^0.10.0", "@types/supertest": "^2.0.8", "msw": "^0.35.0", "supertest": "^6.1.3" diff --git a/plugins/todo/CHANGELOG.md b/plugins/todo/CHANGELOG.md index 99c33c425e..1a6a352e73 100644 --- a/plugins/todo/CHANGELOG.md +++ b/plugins/todo/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-todo +## 0.1.15 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/catalog-model@0.9.7 + - @backstage/plugin-catalog-react@0.6.4 + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.1.14 ### Patch Changes diff --git a/plugins/todo/README.md b/plugins/todo/README.md index 4165582b65..ad0ad4fc1d 100644 --- a/plugins/todo/README.md +++ b/plugins/todo/README.md @@ -2,6 +2,77 @@ This plugin lists `// TODO` comments in source code. It currently exports a single component extension for use on entity pages. +## Setup + +1. Run: + +```bash +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-todo +yarn --cwd packages/backend add @backstage/plugin-todo-backend +``` + +2. Add the plugin backend: + +In a new file named `todo.ts` under `backend/src/plugins`: + +```js +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 }); +} +``` + +And then add to `packages/backend/src/index.ts`: + +```js +// In packages/backend/src/index.ts +import todo from './plugins/todo'; +// ... +async function main() { + // ... + const todoEnv = useHotMemoize(module, () => createEnv('todo')); + // ... + apiRouter.use('/todo', await todo(todoEnv)); +``` + +3. Add the plugin as a tab to your service entities: + +```jsx +// In packages/app/src/components/catalog/EntityPage.tsx +import { EntityTodoContent } from '@backstage/plugin-todo'; + +const serviceEntityPage = ( + + {/* other tabs... */} + + + +``` + ## 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`. diff --git a/plugins/todo/dev/index.tsx b/plugins/todo/dev/index.tsx index a4221dc710..fe547812ec 100644 --- a/plugins/todo/dev/index.tsx +++ b/plugins/todo/dev/index.tsx @@ -22,8 +22,8 @@ import OfflineIcon from '@material-ui/icons/Storage'; import React from 'react'; import { EntityTodoContent, todoApiRef, todoPlugin } from '../src'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { Content, Header, HeaderLabel, Page } from '@backstage/core-components'; +import { TestApiProvider } from '@backstage/test-utils'; const entity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -60,7 +60,7 @@ createDevApp() .registerPlugin(todoPlugin) .addPage({ element: ( - +
@@ -71,7 +71,7 @@ createDevApp() - + ), title: 'Entity Todo Content', icon: OfflineIcon, diff --git a/plugins/todo/package.json b/plugins/todo/package.json index 5e32d84497..1fadc213af 100644 --- a/plugins/todo/package.json +++ b/plugins/todo/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-todo", "description": "A Backstage plugin that lets you browse TODO comments in your source code", - "version": "0.1.14", + "version": "0.1.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,12 +27,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.5", - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", + "@backstage/catalog-model": "^0.9.7", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", "@backstage/errors": "^0.1.3", - "@backstage/plugin-catalog-react": "^0.6.1", - "@backstage/theme": "^0.2.13", + "@backstage/plugin-catalog-react": "^0.6.4", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -41,10 +41,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/todo/src/components/TodoList/TodoList.test.tsx b/plugins/todo/src/components/TodoList/TodoList.test.tsx index 0e4d64172d..2759fe40f8 100644 --- a/plugins/todo/src/components/TodoList/TodoList.test.tsx +++ b/plugins/todo/src/components/TodoList/TodoList.test.tsx @@ -16,11 +16,10 @@ import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { renderWithEffects } from '@backstage/test-utils'; +import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; import React from 'react'; import { TodoApi, todoApiRef } from '../../api'; import { TodoList } from './TodoList'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; describe('TodoList', () => { it('should render', async () => { @@ -42,11 +41,11 @@ describe('TodoList', () => { const mockEntity = { metadata: { name: 'mock' } } as Entity; const rendered = await renderWithEffects( - + - , + , ); await expect(rendered.findByText('FIXME')).resolves.toBeInTheDocument(); diff --git a/plugins/todo/src/routes.ts b/plugins/todo/src/routes.ts index 8ab3fb1b94..01d8d9f354 100644 --- a/plugins/todo/src/routes.ts +++ b/plugins/todo/src/routes.ts @@ -16,5 +16,5 @@ import { createRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'todo', + id: 'todo', }); diff --git a/plugins/user-settings/CHANGELOG.md b/plugins/user-settings/CHANGELOG.md index ae3bc1c72d..8511860dd1 100644 --- a/plugins/user-settings/CHANGELOG.md +++ b/plugins/user-settings/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/plugin-user-settings +## 0.3.12 + +### Patch Changes + +- 9a1c8e92eb: The theme switcher now renders the title of themes instead of their variant +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.3.11 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- 274a4fc633: Add Props Icon for Sidebar Item SidebarSearchField and Settings +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.3.10 ### Patch Changes diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index dc63957cb9..feab9dcf93 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-user-settings", "description": "A Backstage plugin that provides a settings page", - "version": "0.3.10", + "version": "0.3.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", - "@backstage/theme": "^0.2.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -43,10 +43,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx b/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx index 085a884f2e..4e14dec2ef 100644 --- a/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx +++ b/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx @@ -14,22 +14,24 @@ * limitations under the License. */ -import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { + renderWithEffects, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { fireEvent } from '@testing-library/react'; import React from 'react'; import { UserSettingsAuthProviders } from './UserSettingsAuthProviders'; -import { - ApiProvider, - ApiRegistry, - ConfigReader, -} from '@backstage/core-app-api'; +import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { configApiRef, googleAuthApiRef } from '@backstage/core-plugin-api'; const mockSignInHandler = jest.fn().mockReturnValue(''); const mockGoogleAuth = { sessionState$: () => ({ + [Symbol.observable]: jest.fn(), subscribe: () => ({ + closed: false, unsubscribe: () => null, }), }), @@ -47,10 +49,10 @@ const createConfig = () => const config = createConfig(); -const apiRegistry = ApiRegistry.from([ +const apiRegistry = TestApiRegistry.from( [configApiRef, config], [googleAuthApiRef, mockGoogleAuth], -]); +); describe('', () => { it('displays a provider and calls its sign-in handler on click', async () => { diff --git a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx index caae4d69bc..83d9e23f83 100644 --- a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx @@ -15,16 +15,16 @@ */ import { AppTheme, appThemeApiRef } from '@backstage/core-plugin-api'; -import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { + renderWithEffects, + TestApiRegistry, + wrapInTestApp, +} from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { fireEvent } from '@testing-library/react'; import React from 'react'; import { UserSettingsThemeToggle } from './UserSettingsThemeToggle'; -import { - ApiProvider, - ApiRegistry, - AppThemeSelector, -} from '@backstage/core-app-api'; +import { ApiProvider, AppThemeSelector } from '@backstage/core-app-api'; const mockTheme: AppTheme = { id: 'light-theme', @@ -33,8 +33,9 @@ const mockTheme: AppTheme = { theme: lightTheme, }; -const apiRegistry = ApiRegistry.from([ - [appThemeApiRef, AppThemeSelector.createWithStorage([mockTheme])], +const apiRegistry = TestApiRegistry.from([ + appThemeApiRef, + AppThemeSelector.createWithStorage([mockTheme]), ]); describe('', () => { diff --git a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx index 4261218c44..35850b9d82 100644 --- a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.tsx @@ -131,7 +131,7 @@ export const UserSettingsThemeToggle = () => { value={theme.id} > <> - {theme.variant}  + {theme.title}  { + const routePath = useRouteRef(settingsRouteRef); const Icon = props.icon ? props.icon : SettingsIcon; - - return ; + return ; }; diff --git a/plugins/user-settings/src/plugin.ts b/plugins/user-settings/src/plugin.ts index 54f6faceed..01793dc418 100644 --- a/plugins/user-settings/src/plugin.ts +++ b/plugins/user-settings/src/plugin.ts @@ -21,8 +21,7 @@ import { } from '@backstage/core-plugin-api'; export const settingsRouteRef = createRouteRef({ - path: '/settings', - title: 'Settings', + id: 'user-settings', }); export const userSettingsPlugin = createPlugin({ diff --git a/plugins/xcmetrics/CHANGELOG.md b/plugins/xcmetrics/CHANGELOG.md index a7d2d480b7..bf2cdb7a0c 100644 --- a/plugins/xcmetrics/CHANGELOG.md +++ b/plugins/xcmetrics/CHANGELOG.md @@ -1,5 +1,24 @@ # @backstage/plugin-xcmetrics +## 0.2.11 + +### Patch Changes + +- af09b1e059: Fix bug where invalid build statuses led to crashes +- Updated dependencies + - @backstage/core-components@0.7.6 + - @backstage/theme@0.2.14 + - @backstage/core-plugin-api@0.2.2 + +## 0.2.10 + +### Patch Changes + +- a125278b81: Refactor out the deprecated path and icon from RouteRefs +- Updated dependencies + - @backstage/core-components@0.7.4 + - @backstage/core-plugin-api@0.2.0 + ## 0.2.9 ### Patch Changes diff --git a/plugins/xcmetrics/package.json b/plugins/xcmetrics/package.json index bbab66e455..aa2f6169e3 100644 --- a/plugins/xcmetrics/package.json +++ b/plugins/xcmetrics/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-xcmetrics", "description": "A Backstage plugin that shows XCode build metrics for your components", - "version": "0.2.9", + "version": "0.2.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.7.3", - "@backstage/core-plugin-api": "^0.1.13", + "@backstage/core-components": "^0.7.6", + "@backstage/core-plugin-api": "^0.2.2", "@backstage/errors": "^0.1.3", - "@backstage/theme": "^0.2.13", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", @@ -36,10 +36,10 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.8.2", - "@backstage/core-app-api": "^0.1.20", - "@backstage/dev-utils": "^0.2.12", - "@backstage/test-utils": "^0.1.21", + "@backstage/cli": "^0.10.0", + "@backstage/core-app-api": "^0.1.24", + "@backstage/dev-utils": "^0.2.13", + "@backstage/test-utils": "^0.1.23", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx b/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx index 2d949d7abc..7083d890d6 100644 --- a/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx +++ b/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { BuildDetails, withRequest } from './BuildDetails'; import { xcmetricsApiRef } from '../../api'; @@ -35,11 +34,9 @@ jest.mock('../BuildTimeline', () => ({ describe('BuildDetails', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('accordion-Host')).toBeInTheDocument(); @@ -61,11 +58,9 @@ describe('BuildDetails with request', () => { it('should fetch the build and render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText(client.mockBuild.id)).toBeInTheDocument(); @@ -78,11 +73,9 @@ describe('BuildDetails with request', () => { .mockRejectedValue({ message: errorMessage }); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText(errorMessage)).toBeInTheDocument(); @@ -92,11 +85,9 @@ describe('BuildDetails with request', () => { client.XcmetricsClient.getBuild = jest.fn().mockReturnValue(undefined); const rendered = await renderInTestApp( - + - , + , ); expect( diff --git a/plugins/xcmetrics/src/components/BuildList/BuildList.test.tsx b/plugins/xcmetrics/src/components/BuildList/BuildList.test.tsx index eb141209e7..53471f5e9f 100644 --- a/plugins/xcmetrics/src/components/BuildList/BuildList.test.tsx +++ b/plugins/xcmetrics/src/components/BuildList/BuildList.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { BuildList } from './BuildList'; import { xcmetricsApiRef } from '../../api'; import userEvent from '@testing-library/user-event'; @@ -35,11 +34,9 @@ jest.mock('../BuildDetails', () => ({ describe('BuildList', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('Builds')).toBeInTheDocument(); @@ -50,11 +47,9 @@ describe('BuildList', () => { it('should show build details', async () => { const rendered = await renderInTestApp( - + - , + , ); userEvent.click( @@ -70,11 +65,9 @@ describe('BuildList', () => { .mockRejectedValue({ message }); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText(message)).toBeInTheDocument(); diff --git a/plugins/xcmetrics/src/components/BuildListFilter/BuildListFilter.test.tsx b/plugins/xcmetrics/src/components/BuildListFilter/BuildListFilter.test.tsx index b997de68e0..06f4aed1b7 100644 --- a/plugins/xcmetrics/src/components/BuildListFilter/BuildListFilter.test.tsx +++ b/plugins/xcmetrics/src/components/BuildListFilter/BuildListFilter.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import userEvent from '@testing-library/user-event'; import { BuildListFilter } from './BuildListFilter'; import { BuildFilters, xcmetricsApiRef } from '../../api'; @@ -37,14 +36,12 @@ const renderWithFiltersVisible = async ( callback?: (filters: BuildFilters) => void, ) => { const rendered = await renderInTestApp( - + - , + , ); userEvent.click(rendered.getByLabelText('show filters')); @@ -67,14 +64,12 @@ const setProjectFilter = async (rendered: RenderResult, option: string) => { describe('BuildListFilter', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('Filters (0)')).toBeInTheDocument(); diff --git a/plugins/xcmetrics/src/components/Overview/Overview.test.tsx b/plugins/xcmetrics/src/components/Overview/Overview.test.tsx index 3d24d2a422..42e3772d04 100644 --- a/plugins/xcmetrics/src/components/Overview/Overview.test.tsx +++ b/plugins/xcmetrics/src/components/Overview/Overview.test.tsx @@ -14,9 +14,8 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { xcmetricsApiRef } from '../../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import { Overview } from './Overview'; jest.mock('../../api/XcmetricsClient'); @@ -33,11 +32,9 @@ jest.mock('../StatusMatrix', () => ({ describe('Overview', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('XCMetrics Dashboard')).toBeInTheDocument(); @@ -49,9 +46,9 @@ describe('Overview', () => { api.getBuilds = jest.fn().mockResolvedValue([]); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('No builds to show')).toBeInTheDocument(); @@ -64,9 +61,9 @@ describe('Overview', () => { api.getBuilds = jest.fn().mockRejectedValue({ message: errorMessage }); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText(errorMessage)).toBeInTheDocument(); diff --git a/plugins/xcmetrics/src/components/OverviewTrends/OverviewTrends.test.tsx b/plugins/xcmetrics/src/components/OverviewTrends/OverviewTrends.test.tsx index 76b47f4943..db7904a8d1 100644 --- a/plugins/xcmetrics/src/components/OverviewTrends/OverviewTrends.test.tsx +++ b/plugins/xcmetrics/src/components/OverviewTrends/OverviewTrends.test.tsx @@ -15,9 +15,8 @@ */ import React from 'react'; import { OverviewTrends } from './OverviewTrends'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { xcmetricsApiRef } from '../../api'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; import userEvent from '@testing-library/user-event'; jest.mock('../../api/XcmetricsClient'); @@ -26,11 +25,9 @@ const client = require('../../api/XcmetricsClient'); describe('OverviewTrends', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('Trends for')).toBeInTheDocument(); expect(rendered.getAllByText('Build Count').length).toEqual(3); @@ -42,20 +39,18 @@ describe('OverviewTrends', () => { api.getBuildCounts = jest.fn().mockResolvedValue([]); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('--')).toBeInTheDocument(); }); it('should change number of days when select is changed', async () => { const rendered = await renderInTestApp( - + - , + , ); userEvent.click(rendered.getByText('14 days')); @@ -76,9 +71,9 @@ describe('OverviewTrends', () => { .mockRejectedValue({ message: buildTimesError }); const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText(buildCountError)).toBeInTheDocument(); expect(rendered.getByText(buildTimesError)).toBeInTheDocument(); diff --git a/plugins/xcmetrics/src/components/StatusCell/StatusCell.test.tsx b/plugins/xcmetrics/src/components/StatusCell/StatusCell.test.tsx index cac111d13e..6e5b7f73b9 100644 --- a/plugins/xcmetrics/src/components/StatusCell/StatusCell.test.tsx +++ b/plugins/xcmetrics/src/components/StatusCell/StatusCell.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import userEvent from '@testing-library/user-event'; import { StatusCell } from './StatusCell'; import { xcmetricsApiRef } from '../../api'; @@ -27,9 +26,7 @@ const client = require('../../api/XcmetricsClient'); describe('StatusCell', () => { it('should render', async () => { const rendered = await renderInTestApp( - + { size={10} spacing={10} /> - , + , ); userEvent.hover(rendered.getByTestId(client.mockBuild.id)); diff --git a/plugins/xcmetrics/src/components/StatusIcon/StatusIcon.test.tsx b/plugins/xcmetrics/src/components/StatusIcon/StatusIcon.test.tsx index a208432f59..0801a34b48 100644 --- a/plugins/xcmetrics/src/components/StatusIcon/StatusIcon.test.tsx +++ b/plugins/xcmetrics/src/components/StatusIcon/StatusIcon.test.tsx @@ -17,6 +17,7 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import { StatusIcon } from './StatusIcon'; +import { BuildStatus } from '../../api'; describe('StatusIcon', () => { it('should render', async () => { @@ -31,4 +32,11 @@ describe('StatusIcon', () => { rendered = await renderInTestApp(); expect(rendered.getByLabelText('Status warning')).toBeInTheDocument(); }); + + it('should render invalid statuses', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByLabelText('Status aborted')).toBeInTheDocument(); + }); }); diff --git a/plugins/xcmetrics/src/components/StatusIcon/StatusIcon.tsx b/plugins/xcmetrics/src/components/StatusIcon/StatusIcon.tsx index 610575b817..f28b5e4fe4 100644 --- a/plugins/xcmetrics/src/components/StatusIcon/StatusIcon.tsx +++ b/plugins/xcmetrics/src/components/StatusIcon/StatusIcon.tsx @@ -15,6 +15,7 @@ */ import React from 'react'; import { + StatusAborted, StatusError, StatusOK, StatusWarning, @@ -32,4 +33,4 @@ interface StatusIconProps { } export const StatusIcon = ({ buildStatus }: StatusIconProps) => - STATUS_ICONS[buildStatus]; + STATUS_ICONS[buildStatus] ?? ; diff --git a/plugins/xcmetrics/src/components/StatusMatrix/StatusMatrix.test.tsx b/plugins/xcmetrics/src/components/StatusMatrix/StatusMatrix.test.tsx index 7d3854bbda..f18d322b43 100644 --- a/plugins/xcmetrics/src/components/StatusMatrix/StatusMatrix.test.tsx +++ b/plugins/xcmetrics/src/components/StatusMatrix/StatusMatrix.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { StatusMatrix } from './StatusMatrix'; import { xcmetricsApiRef } from '../../api'; @@ -25,11 +24,9 @@ const client = require('../../api/XcmetricsClient'); describe('StatusMatrix', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); const cell = rendered.getByTestId(client.mockBuild.id); diff --git a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.test.tsx b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.test.tsx index 84bd25624c..29d6407b03 100644 --- a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.test.tsx +++ b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.test.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { XcmetricsLayout } from './XcmetricsLayout'; import { xcmetricsApiRef } from '../../api'; import userEvent from '@testing-library/user-event'; @@ -34,11 +33,9 @@ jest.mock('../BuildList', () => ({ describe('XcmetricsLayout', () => { it('should render', async () => { const rendered = await renderInTestApp( - + - , + , ); expect(rendered.getByText('Overview')).toBeInTheDocument(); @@ -49,11 +46,9 @@ describe('XcmetricsLayout', () => { it('should show a list of builds when the Builds tab is selected', async () => { const rendered = await renderInTestApp( - + - , + , ); userEvent.click(rendered.getByText('Builds')); diff --git a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx index 53545890a4..a21918e322 100644 --- a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx +++ b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx @@ -22,24 +22,23 @@ import { TabbedLayout, } from '@backstage/core-components'; import { Overview } from '../Overview'; -import { buildsRouteRef, rootRouteRef } from '../../routes'; -import { RouteRef, SubRouteRef } from '@backstage/core-plugin-api'; +import { buildsRouteRef } from '../../routes'; import { BuildsPage } from '../BuildsPage'; export interface TabConfig { - routeRef: RouteRef | SubRouteRef; + path: string; title: string; component: ReactChild; } const TABS: TabConfig[] = [ { - routeRef: rootRouteRef, + path: '/', title: 'Overview', component: , }, { - routeRef: buildsRouteRef, + path: buildsRouteRef.path, title: 'Builds', component: , }, @@ -53,11 +52,7 @@ export const XcmetricsLayout = () => (
{TABS.map(tab => ( - + {tab.component} ))} diff --git a/plugins/xcmetrics/src/routes.ts b/plugins/xcmetrics/src/routes.ts index 212ecbae48..81934c860f 100644 --- a/plugins/xcmetrics/src/routes.ts +++ b/plugins/xcmetrics/src/routes.ts @@ -16,7 +16,7 @@ import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ - title: 'XCMetrics', + id: 'xcmetrics', }); export const buildsRouteRef = createSubRouteRef({ diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 22eae267ee..8ca7743cf2 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -35,6 +35,7 @@ import { import { Program } from 'typescript'; import { DocNode, + DocSection, IDocNodeContainerParameters, TSDocTagSyntaxKind, } from '@microsoft/tsdoc'; @@ -50,6 +51,7 @@ import { DocHeading } from '@microsoft/api-documenter/lib/nodes/DocHeading'; import { CustomMarkdownEmitter } from '@microsoft/api-documenter/lib/markdown/CustomMarkdownEmitter'; import { IMarkdownEmitterContext } from '@microsoft/api-documenter/lib/markdown/MarkdownEmitter'; import { AstDeclaration } from '@microsoft/api-extractor/lib/analyzer/AstDeclaration'; +import { DocTableCell } from '@microsoft/api-documenter/lib/nodes/DocTableCell'; const tmpDir = resolvePath(__dirname, '../node_modules/.cache/api-extractor'); @@ -603,9 +605,18 @@ async function buildDocs({ }); for (const apiMember of apiModel.members) { + // This is a workaround for this check failing: https://github.com/microsoft/rushstack/blob/915aca8d8847b65981892f44f0544ccb00752792/apps/api-documenter/src/documenters/MarkdownDocumenter.ts#L991 + const description = new DocSection({ configuration }); + if (apiMember.tsdocComment !== undefined) { + this._appendAndMergeSection( + description, + apiMember.tsdocComment.summarySection, + ); + } + const row = new DocTableRow({ configuration }, [ this._createTitleCell(apiMember), - this._createDescriptionCell(apiMember), + new DocTableCell({ configuration }, description.nodes), ]); if (apiMember.kind === 'Package') { diff --git a/scripts/check-if-release.js b/scripts/check-if-release.js index 4475ed6207..b61b0e2eb7 100755 --- a/scripts/check-if-release.js +++ b/scripts/check-if-release.js @@ -96,7 +96,9 @@ async function main() { const newVersions = packageVersions.filter( ({ oldVersion, newVersion }) => - oldVersion !== newVersion && newVersion !== '', + oldVersion !== newVersion && + oldVersion !== '' && + newVersion !== '', ); if (newVersions.length === 0) { diff --git a/scripts/list-deprecations.js b/scripts/list-deprecations.js new file mode 100755 index 0000000000..920b01c7b5 --- /dev/null +++ b/scripts/list-deprecations.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +const _ = require('lodash'); +const fs = require('fs-extra'); +const globby = require('globby'); +const { resolve: resolvePath, relative: relativePath } = require('path'); +const { execFile: execFileCb } = require('child_process'); +const { promisify } = require('util'); + +const execFile = promisify(execFileCb); + +const WORKER_COUNT = 16; + +const deprecatedPattern = /@deprecated|DEPRECATION/; + +class ReleaseProvider { + cache = new Map(); + + constructor(rootPath) { + this.rootPath = rootPath; + } + + async lookup(commitSha, packageDir) { + const key = commitSha + packageDir; + + if (this.cache.has(key)) { + return this.cache.get(key); + } + + const pkgJsonPath = relativePath( + this.rootPath, + resolvePath(this.rootPath, packageDir, 'package.json'), + ); + const releasePromise = Promise.resolve().then(async () => { + // Find all tags that contain the commit + const { stdout: tagOutput } = await execFile( + 'git', + ['tag', '--contains', commitSha], + { shell: true }, + ); + + // Filter out just the releases + const releases = tagOutput + .split('\n') + .filter(l => l.startsWith('release-')); + + // Then find the earliest release that affected our package + for (const release of releases) { + let oldVersion; + let newVersion; + + try { + const { stdout: content } = await execFile( + 'git', + ['show', `${release}^:${pkgJsonPath}`], + { shell: true }, + ); + oldVersion = JSON.parse(content).version; + } catch { + /* */ + } + try { + const { stdout: content } = await execFile( + 'git', + ['show', `${release}:${pkgJsonPath}`], + { shell: true }, + ); + newVersion = JSON.parse(content).version; + } catch { + /* */ + } + + if (oldVersion !== newVersion) { + return release; + } + } + return undefined; + }); + + this.cache.set(key, releasePromise); + return releasePromise; + } +} + +async function main() { + let packageDirQueue = process.argv.slice(2); + + const rootPath = resolvePath(__dirname, '..'); + + if (packageDirQueue.length === 0) { + packageDirQueue = await Promise.all([ + fs.readdir(resolvePath(rootPath, 'packages')), + fs.readdir(resolvePath(rootPath, 'plugins')), + ]).then(([packages, plugins]) => [ + ...packages.map(dir => `packages/${dir}`), + ...plugins.map(dir => `plugins/${dir}`), + ]); + } + + const fileQueue = []; + const deprecationQueue = []; + const releaseProvider = new ReleaseProvider(rootPath); + const deprecations = []; + + await Promise.all( + Array(WORKER_COUNT) + .fill() + .map(async () => { + // Find all files we want to scan + while (packageDirQueue.length) { + const packageDir = packageDirQueue.pop(); + const srcDir = resolvePath(rootPath, packageDir, 'src'); + + if (await fs.pathExists(srcDir)) { + const files = await globby(['**/*.{js,jsx,ts,tsx}'], { + cwd: srcDir, + }); + fileQueue.push( + ...files.map(file => ({ + packageDir, + file: resolvePath(srcDir, file), + })), + ); + } + } + + // Parse files and search for deprecations + while (fileQueue.length) { + const { packageDir, file } = fileQueue.pop(); + const content = await fs.readFile(file, 'utf8'); + if (!deprecatedPattern.test(content)) { + continue; + } + + const lines = content.split('\n'); + for (const [index, line] of lines.entries()) { + if (deprecatedPattern.test(line)) { + deprecationQueue.push({ + packageDir, + file, + lineNumber: index + 1, + lineContent: line, + }); + } + } + } + + // Lookup git information for each deprecation + while (deprecationQueue.length) { + const deprecation = deprecationQueue.pop(); + + const { file, packageDir, lineNumber: n, lineContent } = deprecation; + const { stdout: blameOutput } = await execFile( + 'git', + ['blame', '--porcelain', `-L ${n},${n}`, file], + { shell: true }, + ); + + const blameInfo = Object.fromEntries( + blameOutput + .split('\n') + .slice(1, -2) + .map(line => { + const [key] = line.split(' ', 1); + return [key, line.slice(key.length + 1)]; + }), + ); + const [commit] = blameOutput.split(' ', 1); + const { author, ['author-time']: authorTime } = blameInfo; + + const release = await releaseProvider.lookup(commit, packageDir); + + deprecations.push({ + file: relativePath(rootPath, file), + release, + commit, + author, + authorTime: new Date(authorTime * 1000), + lineContent, + lineNumber: n, + }); + } + }), + ); + + const maxAuthor = Math.max(...deprecations.map(d => d.author.length)) + 1; + + // Group and sort by release + const sortedByRelease = _.sortBy( + Object.entries(_.groupBy(deprecations, 'release')), + ([release]) => release, + ); + + for (const [release, ds] of sortedByRelease) { + console.log(`\n### ${release === 'undefined' ? 'Not released' : release}`); + for (const d of _.sortBy(ds, 'authorTime', 'file', 'lineNumber')) { + console.log( + [ + d.commit.slice(0, 8), + d.authorTime.toLocaleDateString().padEnd('00/00/0000'.length + 1), + d.author.padEnd(maxAuthor + 1), + `${d.file}:${d.lineNumber}`, + ].join(' '), + ); + } + } +} + +main().catch(err => { + console.error(err.stack); + process.exit(1); +}); diff --git a/scripts/migrate-location-types.js b/scripts/migrate-location-types.js index d6ac5c6a2a..168a5e825c 100755 --- a/scripts/migrate-location-types.js +++ b/scripts/migrate-location-types.js @@ -20,7 +20,7 @@ // catalog API endpoint and execute the script. It will delete and add // back the locations with the correct type one by one. -const BASE_URL = 'http://localhost:7000/api/catalog'; // Change me +const BASE_URL = 'http://localhost:7007/api/catalog'; // Change me const deprecatedTypes = [ 'github', @@ -81,9 +81,9 @@ async function request(method, url, body) { return; } try { - const body = Buffer.concat(chunks).toString('utf8').trim(); - if (body) { - resolve(JSON.parse(body)); + const responseBody = Buffer.concat(chunks).toString('utf8').trim(); + if (responseBody) { + resolve(JSON.parse(responseBody)); } else { resolve(); } diff --git a/yarn.lock b/yarn.lock index debcf79d8e..c28d72146b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2078,10 +2078,10 @@ core-js-pure "^3.16.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.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.8", "@babel/runtime@^7.3.1", "@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.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": - version "7.15.4" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz#fd17d16bfdf878e6dd02d19753a39fa8a8d9c84a" - integrity sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw== +"@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.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.6", "@babel/runtime@^7.14.8", "@babel/runtime@^7.3.1", "@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.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": + version "7.16.3" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5" + integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ== dependencies: regenerator-runtime "^0.13.4" @@ -2172,7 +2172,7 @@ "@babel/helper-validator-identifier" "^7.14.9" to-fast-properties "^2.0.0" -"@backstage/catalog-client@^0.3.16", "@backstage/catalog-client@^0.3.18": +"@backstage/catalog-client@^0.3.18": version "0.3.19" resolved "https://registry.npmjs.org/@backstage/catalog-client/-/catalog-client-0.3.19.tgz#109b0fde1cc2d9a306635f3775fc8f6174172b14" integrity sha512-0ydcC/1ISNgR8SggOUnMNNHtsRwrKN5GX+AXgTVdZk4qpz1MqG1+fzrNld9V7KVvXdsbGDZAl/b5a7/FJEpCqg== @@ -2360,6 +2360,24 @@ remark-gfm "^1.0.0" zen-observable "^0.8.15" +"@backstage/core-plugin-api@^0.1.10", "@backstage/core-plugin-api@^0.1.3", "@backstage/core-plugin-api@^0.1.4", "@backstage/core-plugin-api@^0.1.6", "@backstage/core-plugin-api@^0.1.8": + version "0.1.13" + resolved "https://registry.npmjs.org/@backstage/core-plugin-api/-/core-plugin-api-0.1.13.tgz#583c0fe3440450c304f13dcc85eac3bd80f357d7" + integrity sha512-BWEqleTXR7m7nsCyt6cEnc+Gx/VT4vLdNr9fc1kXfzSAA7cUT4VKjlVmm5kdyih3BSQ4+0k7Pm/NIf0jfa7rlg== + dependencies: + "@backstage/config" "^0.1.11" + "@backstage/theme" "^0.2.13" + "@backstage/types" "^0.1.1" + "@backstage/version-bridge" "^0.1.0" + "@material-ui/core" "^4.12.2" + "@types/react" "*" + history "^5.0.0" + prop-types "^15.7.2" + react "^16.12.0" + react-router-dom "6.0.0-beta.0" + react-use "^17.2.4" + zen-observable "^0.8.15" + "@backstage/plugin-catalog-react@^0.4.0": version "0.4.6" resolved "https://registry.npmjs.org/@backstage/plugin-catalog-react/-/plugin-catalog-react-0.4.6.tgz#f33f3cd734f110d3c547f7eae4e25d14806ec796" @@ -2632,9 +2650,9 @@ figures "^1.7.0" "@cypress/request@^2.88.5": - version "2.88.5" - resolved "https://registry.npmjs.org/@cypress/request/-/request-2.88.5.tgz#8d7ecd17b53a849cfd5ab06d5abe7d84976375d7" - integrity sha512-TzEC1XMi1hJkywWpRfD2clreTa/Z+lOrXDCxxBTBPEcY5azdPi56A6Xw+O4tWJnaJH3iIE7G5aDXZC6JgRZLcA== + version "2.88.10" + resolved "https://registry.npmjs.org/@cypress/request/-/request-2.88.10.tgz#b66d76b07f860d3a4b8d7a0604d020c662752cce" + integrity sha512-Zp7F+R93N0yZyG34GutyTNr+okam7s/Fzc1+i3kcqOP8vk6OuajuE9qZJ6Rs+10/1JFtXFYMdyarnU1rZuJesg== dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -2643,19 +2661,17 @@ extend "~3.0.2" forever-agent "~0.6.1" form-data "~2.3.2" - har-validator "~5.1.3" - http-signature "~1.2.0" + http-signature "~1.3.6" is-typedarray "~1.0.0" isstream "~0.1.2" json-stringify-safe "~5.0.1" mime-types "~2.1.19" - oauth-sign "~0.9.0" performance-now "^2.1.0" qs "~6.5.2" safe-buffer "^5.1.2" tough-cookie "~2.5.0" tunnel-agent "^0.6.0" - uuid "^3.3.2" + uuid "^8.3.2" "@cypress/xvfb@^1.2.4": version "1.2.4" @@ -2861,35 +2877,36 @@ resolved "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.2.tgz#30aa825f11d438671d585bd44e7fd564535fc210" integrity sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw== -"@gitbeaker/core@^30.2.0", "@gitbeaker/core@^30.3.0": - version "30.3.0" - resolved "https://registry.npmjs.org/@gitbeaker/core/-/core-30.3.0.tgz#d005891d47cfacb41d4a3cc8bf3ee8c68c53d378" - integrity sha512-j7GHsFo6AOuRmLaK4F4Kx967jTy6LFZh5vjmRxjlRvX0qlfl3NJKiQIl30fZz1rPXWdQLAq+kwl1i0BKrWhOpA== +"@gitbeaker/core@^34.6.0": + version "34.6.0" + resolved "https://registry.npmjs.org/@gitbeaker/core/-/core-34.6.0.tgz#f774ea98ac079ba2edf495fdef738ac3f741178b" + integrity sha512-yKF+oxffPyzOnyuHCqLGJrBHhcFHuGHtcmqKhGKtnYPfqcNYA8rt4INAHaE5wMz4ILua9b4sB8p42fki+xn6WA== dependencies: - "@gitbeaker/requester-utils" "^30.3.0" + "@gitbeaker/requester-utils" "^34.6.0" form-data "^4.0.0" li "^1.3.0" + mime "^3.0.0" query-string "^7.0.0" xcase "^2.0.1" -"@gitbeaker/node@^30.2.0": - version "30.3.0" - resolved "https://registry.npmjs.org/@gitbeaker/node/-/node-30.3.0.tgz#ceebde08a13d3f655fa614a4ced56eb1f0a71ec1" - integrity sha512-Ythbadb1+yMO2hSgvp3nvaroHTkI4+mdLg1rdV3YNIF1n+kUYXQD2GY7wpAjH0KjAnBAmgxw/JsFmlfnRu3KAg== +"@gitbeaker/node@^34.6.0": + version "34.6.0" + resolved "https://registry.npmjs.org/@gitbeaker/node/-/node-34.6.0.tgz#104f122433b65ceb45b0e645001d15cbcc9b1280" + integrity sha512-gVV4Wuev43Jbyoy1fszC885+bkvWH4zWiUhtIu0PSAm628j/OxO7idLIqUEMV0hDf6wm/PE/vOSP6PhjE0N+fA== dependencies: - "@gitbeaker/core" "^30.3.0" - "@gitbeaker/requester-utils" "^30.3.0" + "@gitbeaker/core" "^34.6.0" + "@gitbeaker/requester-utils" "^34.6.0" delay "^5.0.0" got "^11.8.2" xcase "^2.0.1" -"@gitbeaker/requester-utils@^30.3.0": - version "30.3.0" - resolved "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-30.3.0.tgz#fbbaae20263ff90952da116d8782f74f47b29c81" - integrity sha512-b5vCaUqP2Jrqdpt5CkmFET4VFHwJYjnIovwZGYd5H0aKmiPw4NmeW1JtP+65J4xf0c4AOQQj6XSf7TZLuPNAqw== +"@gitbeaker/requester-utils@^34.6.0": + version "34.6.0" + resolved "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-34.6.0.tgz#4489009b759ca6f9a83f244453f4f610f1ac7349" + integrity sha512-H8utxbSP1kEdX0KcyVYrTDTT0A3UcPwrIV1ahyufX9ZLybYSUsA56B8Wx5kJSbWGFT1ffu2f8H2YDMwNCKKsBg== dependencies: form-data "^4.0.0" - query-string "^7.0.0" + qs "^6.10.1" xcase "^2.0.1" "@google-cloud/common@^3.7.0": @@ -3636,6 +3653,17 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@jest/types@^27.2.5": + version "27.2.5" + resolved "https://registry.npmjs.org/@jest/types/-/types-27.2.5.tgz#420765c052605e75686982d24b061b4cbba22132" + integrity sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^16.0.0" + chalk "^4.0.0" + "@josephg/resolvable@^1.0.0": version "1.0.1" resolved "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz#69bc4db754d79e1a2f17a650d3466e038d94a5eb" @@ -3646,12 +3674,12 @@ resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== -"@kubernetes/client-node@^0.15.0": - version "0.15.0" - resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.15.0.tgz#aa5cfcfa9ba3055fe0b510c430d19bbda715d8e7" - integrity sha512-AnEcsWWadl5IWOzzvO/gWpTnJb1d1CzA/rbV/qK1c0fD1SOxTDPj6jFllyQ9icGDfCgNw3TafZftmuepm6z9JA== +"@kubernetes/client-node@^0.16.0": + version "0.16.1" + resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.16.1.tgz#c78ef667579777c1a532983922807e228dbc9b90" + integrity sha512-/Ah+3gFSjXFeqDMGGTyYBKug44Eu2D2qowKLdiZqxCkHdSNgy+CNk6FU1Vy80WrTvGkF/CZr4az6O5AopAiJEw== dependencies: - "@types/js-yaml" "^3.12.1" + "@types/js-yaml" "^4.0.1" "@types/node" "^10.12.0" "@types/request" "^2.47.1" "@types/stream-buffers" "^3.0.3" @@ -3661,7 +3689,7 @@ byline "^5.0.0" execa "5.0.0" isomorphic-ws "^4.0.1" - js-yaml "^3.13.1" + js-yaml "^4.1.0" jsonpath-plus "^0.19.0" openid-client "^4.1.1" request "^2.88.0" @@ -5054,19 +5082,19 @@ resolved "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-2.0.0.tgz#1108b9ea661ca6c81e4a8bfa63a09eb27d5bc2db" integrity sha512-35cfQ4YWlnZnmZKmIxlGPUPLtbkF8lr/A/1Sk1eC0ddLMwQN06dOuLc+dI3YLQS+T+MoNt3DIQ0NynwgKPilig== -"@octokit/webhooks-types@4.12.0": - version "4.12.0" - resolved "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-4.12.0.tgz#add8b98d60ab42e965c4ba31040097f810e222de" - integrity sha512-G0k7CoS9bK+OI7kPHgqi1KqK4WhrjDQSjy0wJI+0OTx/xvbHUIZDeqatY60ceeRINP/1ExEk6kTARboP0xavEw== +"@octokit/webhooks-types@4.15.0": + version "4.15.0" + resolved "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-4.15.0.tgz#1158cba6578237d60957a37963a4a05654f5668b" + integrity sha512-s9LgKsUzq/JH3PWDjaD/m1DIlC/QWgBWbmXVqjdxJXJQBA67KZrLWjStVlYPf0mWlVZ1MOKphDyHiOGCbs0+Kg== "@octokit/webhooks@^9.14.1": - version "9.17.0" - resolved "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-9.17.0.tgz#81140b2e127157aa9817d085cd8758545e4a72e4" - integrity sha512-/+9WSLuDuoqNWnMY4w6ePioILBqsUOiUt0ygQzugYzd112WB+yPIjmUQjAbNXImDsAa1myLpBICAMQDZlULyAA== + version "9.18.0" + resolved "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-9.18.0.tgz#19cc70e1ef281e33d830ea23e8011d25d8051f7f" + integrity sha512-N2hP7vCouKk9UWZxvqgWTPbp34i6g9Om/jk+TZeZ5Z+VsKjXvGtONlEd9H8DM1yOeEC+ARDpfhraX6UsK5tesQ== dependencies: "@octokit/request-error" "^2.0.2" "@octokit/webhooks-methods" "^2.0.0" - "@octokit/webhooks-types" "4.12.0" + "@octokit/webhooks-types" "4.15.0" aggregate-error "^3.1.0" "@open-draft/until@^1.0.3": @@ -5189,25 +5217,25 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" -"@rjsf/core@^3.0.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@rjsf/core/-/core-3.2.0.tgz#189ff7fb132cb223463792d75fb1e04e9dd5d9f9" - integrity sha512-Cgonq9bBWlpE0yeeIv7rAIDuJvO1wAvp9G7tT/UvhODAyb4tDb1q8J/8ubWuRRiEyzYUkuxN0W8uPIzQsJgtTQ== +"@rjsf/core@^3.2.1": + version "3.2.1" + resolved "https://registry.npmjs.org/@rjsf/core/-/core-3.2.1.tgz#8a7b24c9a6f01f0ecb093fdfc777172c12b1b009" + integrity sha512-dk8ihvxFbcuIwU7G+HiJbFgwyIvaumPt5g5zfnuC26mwTUPlaDGFXKK2yITp8tJ3+hcwS5zEXtAN9wUkfuM4jA== dependencies: "@types/json-schema" "^7.0.7" ajv "^6.7.0" core-js-pure "^3.6.5" json-schema-merge-allof "^0.6.0" - jsonpointer "^4.0.1" + jsonpointer "^5.0.0" lodash "^4.17.15" nanoid "^3.1.23" prop-types "^15.7.2" react-is "^16.9.0" -"@rjsf/material-ui@^3.0.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-3.1.0.tgz#ed15fc75de1594f87cd8336b7f2ebc492a432d8b" - integrity sha512-kWz37spT5SOXkb8Axq4g4BzQjXRylQr6B7eFAg1NPhSK7KrJYwSMSsFJ9Ze2vEBxwEiR0Z0n/4puaamGuGFRBw== +"@rjsf/material-ui@^3.2.1": + version "3.2.1" + resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-3.2.1.tgz#84fbf322485aee3a84101e189161f0687779ec8d" + integrity sha512-8UiDeDbjCImFSfOegGu13otQ7OdP9FOYpcLjeouppnhs+MPeIEAtYS+jCcBKmi3reyTagC15/KVSRhde1wS1vg== "@roadiehq/backstage-plugin-buildkite@^1.0.8": version "1.0.8" @@ -5316,7 +5344,7 @@ magic-string "^0.25.7" resolve "^1.17.0" -"@rollup/plugin-json@^4.0.2": +"@rollup/plugin-json@^4.1.0": version "4.1.0" resolved "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3" integrity sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw== @@ -5335,10 +5363,10 @@ is-module "^1.0.0" resolve "^1.19.0" -"@rollup/plugin-yaml@^3.0.0": - version "3.0.0" - resolved "https://registry.npmjs.org/@rollup/plugin-yaml/-/plugin-yaml-3.0.0.tgz#632af397a6faaea10c885680c575ae01f4556c1d" - integrity sha512-nXYI3WsfG7Oq5K5k7evGQ1ZABGCPhHxMaMX4T3GRSDi9VyhkaU1+8R1RzTRFXITFJHx0QEGn8jR+P9pux5FuBQ== +"@rollup/plugin-yaml@^3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@rollup/plugin-yaml/-/plugin-yaml-3.1.0.tgz#03a13039ba366fc8d39a1ab94a7debacdd776c2f" + integrity sha512-61PsAXqN7YNYdg/nezK3NkqAu6e3Qu2wjHYW3r52Nx0aLi+rG7gkkIqtvxG8EtSqE2rra5CUcWBZj+v362qt9A== dependencies: "@rollup/pluginutils" "^3.1.0" js-yaml "^3.14.0" @@ -5353,10 +5381,10 @@ estree-walker "^1.0.1" picomatch "^2.2.2" -"@rollup/pluginutils@^4.1.0": - version "4.1.0" - resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.1.0.tgz#0dcc61c780e39257554feb7f77207dceca13c838" - integrity sha512-TrBhfJkFxA+ER+ew2U2/fHbebhLT/l/2pRk0hfj9KusXUuRXd2v0R58AfaZK9VXDQ4TogOSEmICVrQAA3zFnHQ== +"@rollup/pluginutils@^4.1.1": + version "4.1.1" + resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.1.1.tgz#1d4da86dd4eded15656a57d933fda2b9a08d47ec" + integrity sha512-clDjivHqWGXi7u+0d2r2sBi4Ie6VLEAzWMIkvJLnDmxoOhBYOTfzGbOQBA32THHm11/LiJbd01tJUpJsbshSWQ== dependencies: estree-walker "^2.0.1" picomatch "^2.2.2" @@ -5510,10 +5538,10 @@ resolved "https://registry.npmjs.org/@spotify/eslint-config-typescript/-/eslint-config-typescript-12.0.0.tgz#4c7af3f74a47668bec0c860b72e2a0103e78a138" integrity sha512-nMVll8ZkN/W8+IHn6Iz3YzCKW0qhrn3TVfyxkAr3qmXm5cex+GzyUdZEuxb8rdN2inZL6A1Il2NFfO5p/UKxog== -"@spotify/prettier-config@^11.0.0": - version "11.0.0" - resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-11.0.0.tgz#d91e0546a8c1c0f7299e2edc7e44306e9be210f6" - integrity sha512-dOI13j1uHMZkRxhZuge/ugOE7Aqcg7Nxki932lDZuXyY4G8CGxkc/66PeQ8pR4PCzThHORXo7Ptvau6bh101lQ== +"@spotify/prettier-config@^12.0.0": + version "12.0.0" + resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-12.0.0.tgz#936ca5e977cfccbccd1731ab98b1f2bf65852b5d" + integrity sha512-64WWqE40U/WwWV8iIQBseTU+b2t+SdJSyQoCLdVPCKM9uf7KOjRivVwXe4KlWoV3y7duNSGuB2UgWhkXzscVmQ== "@storybook/addon-a11y@^6.3.4": version "6.3.12" @@ -5538,16 +5566,16 @@ util-deprecate "^1.0.2" "@storybook/addon-actions@^6.1.11": - version "6.3.7" - resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.3.7.tgz#b25434972bef351aceb3f7ec6fd66e210f256aac" - integrity sha512-CEAmztbVt47Gw1o6Iw0VP20tuvISCEKk9CS/rCjHtb4ubby6+j/bkp3pkEUQIbyLdHiLWFMz0ZJdyA/U6T6jCw== + version "6.3.12" + resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.3.12.tgz#69eb5f8f780f1b00456051da6290d4b959ba24a0" + integrity sha512-mzuN4Ano4eyicwycM2PueGzzUCAEzt9/6vyptWEIVJu0sjK0J9KtBRlqFi1xGQxmCfimDR/n/vWBBkc7fp2uJA== dependencies: - "@storybook/addons" "6.3.7" - "@storybook/api" "6.3.7" - "@storybook/client-api" "6.3.7" - "@storybook/components" "6.3.7" - "@storybook/core-events" "6.3.7" - "@storybook/theming" "6.3.7" + "@storybook/addons" "6.3.12" + "@storybook/api" "6.3.12" + "@storybook/client-api" "6.3.12" + "@storybook/components" "6.3.12" + "@storybook/core-events" "6.3.12" + "@storybook/theming" "6.3.12" core-js "^3.8.2" fast-deep-equal "^3.1.3" global "^4.4.0" @@ -5823,19 +5851,6 @@ qs "^6.10.0" telejson "^5.3.2" -"@storybook/channel-postmessage@6.3.7": - version "6.3.7" - resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.3.7.tgz#bd4edf84a29aa2cd4a22d26115c60194d289a840" - integrity sha512-Cmw8HRkeSF1yUFLfEIUIkUICyCXX8x5Ol/5QPbiW9HPE2hbZtYROCcg4bmWqdq59N0Tp9FQNSn+9ZygPgqQtNw== - dependencies: - "@storybook/channels" "6.3.7" - "@storybook/client-logger" "6.3.7" - "@storybook/core-events" "6.3.7" - core-js "^3.8.2" - global "^4.4.0" - qs "^6.10.0" - telejson "^5.3.2" - "@storybook/channels@6.3.11": version "6.3.11" resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.3.11.tgz#a14ce233367a9072bd1cffef3825f125c27bb0ae" @@ -5911,30 +5926,6 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-api@6.3.7": - version "6.3.7" - resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.3.7.tgz#cb1dca05467d777bd09aadbbdd1dd22ca537ce14" - integrity sha512-8wOH19cMIwIIYhZy5O5Wl8JT1QOL5kNuamp9GPmg5ff4DtnG+/uUslskRvsnKyjPvl+WbIlZtBVWBiawVdd/yQ== - dependencies: - "@storybook/addons" "6.3.7" - "@storybook/channel-postmessage" "6.3.7" - "@storybook/channels" "6.3.7" - "@storybook/client-logger" "6.3.7" - "@storybook/core-events" "6.3.7" - "@storybook/csf" "0.0.1" - "@types/qs" "^6.9.5" - "@types/webpack-env" "^1.16.0" - core-js "^3.8.2" - global "^4.4.0" - lodash "^4.17.20" - memoizerific "^1.11.3" - qs "^6.10.0" - regenerator-runtime "^0.13.7" - stable "^0.1.8" - store2 "^2.12.0" - ts-dedent "^2.0.0" - util-deprecate "^1.0.2" - "@storybook/client-logger@6.3.11": version "6.3.11" resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.3.11.tgz#d2e0e17f35ed4c5ee282d818b6db3a015ce6b833" @@ -6633,13 +6624,13 @@ dependencies: defer-to-connect "^2.0.0" -"@testing-library/cypress@^7.0.1": - version "7.0.6" - resolved "https://registry.npmjs.org/@testing-library/cypress/-/cypress-7.0.6.tgz#5445dac4f4852c26901c356e9d3a69371bd20ccf" - integrity sha512-atnjqlkEt6spU4Mv7evvpA8fMXeRw7AN2uTKOR1dP6WBvBixVwAYMZY+1fMOaZULWAj9vGLCXXvmw++u3TxuCQ== +"@testing-library/cypress@^8.0.2": + version "8.0.2" + resolved "https://registry.npmjs.org/@testing-library/cypress/-/cypress-8.0.2.tgz#b13f0ff2424dec4368b6670dfbfb7e43af8eefc9" + integrity sha512-KVdm7n37sg/A4e3wKMD4zUl0NpzzVhx06V9Tf0hZHZ7nrZ4yFva6Zwg2EFF1VzHkEfN/ahUzRtT1qiW+vuWnJw== dependencies: - "@babel/runtime" "^7.12.5" - "@testing-library/dom" "^7.28.1" + "@babel/runtime" "^7.14.6" + "@testing-library/dom" "^8.1.0" "@testing-library/dom@^7.28.1": version "7.29.6" @@ -6655,6 +6646,20 @@ lz-string "^1.4.4" pretty-format "^26.6.2" +"@testing-library/dom@^8.1.0": + version "8.11.1" + resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.1.tgz#03fa2684aa09ade589b460db46b4c7be9fc69753" + integrity sha512-3KQDyx9r0RKYailW2MiYrSSKEfH0GTkI51UGEvJenvcoDoeRYs0PZpi2SXqtnMClQvCqdtTTpOfFETDTVADpAg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^4.2.0" + aria-query "^5.0.0" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.4.4" + pretty-format "^27.0.2" + "@testing-library/jest-dom@^5.10.1": version "5.14.1" resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.14.1.tgz#8501e16f1e55a55d675fe73eecee32cdaddb9766" @@ -6724,6 +6729,11 @@ axios-cached-dns-resolve "0.5.2" file-type "16.5.3" +"@trysound/sax@0.2.0": + version "0.2.0" + resolved "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" + integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== + "@tsconfig/node10@^1.0.7": version "1.0.8" resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" @@ -6763,6 +6773,13 @@ dependencies: "@types/glob" "*" +"@types/archiver@^5.1.1": + version "5.1.1" + resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.1.1.tgz#d6d7610de4386b293abd5c1cb1875e0a4f4e1c30" + integrity sha512-heuaCk0YH5m274NOLSi66H1zX6GtZoMsdE6TYFcpFFjBjg0FoU4i4/M/a/kNlgNg26Xk3g364mNOYe1JaiEPOQ== + dependencies: + "@types/glob" "*" + "@types/argparse@1.0.38": version "1.0.38" resolved "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9" @@ -7055,19 +7072,19 @@ "@types/ms" "*" "@types/diff@^5.0.0": - version "5.0.0" - resolved "https://registry.npmjs.org/@types/diff/-/diff-5.0.0.tgz#eb71e94feae62548282c4889308a3dfb57e36020" - integrity sha512-jrm2K65CokCCX4NmowtA+MfXyuprZC13jbRuwprs6/04z/EcFg/MCwYdsHn+zgV4CQBiATiI7AEq7y1sZCtWKA== + version "5.0.1" + resolved "https://registry.npmjs.org/@types/diff/-/diff-5.0.1.tgz#9c9b9a331d4e41ccccff553f5d7ef964c6cf4042" + integrity sha512-XIpxU6Qdvp1ZE6Kr3yrkv1qgUab0fyf4mHYvW8N3Bx3PCsbN6or1q9/q72cv5jIFWolaGH08U9XyYoLLIykyKQ== "@types/docker-modem@*": - version "3.0.1" - resolved "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.1.tgz#2a29c914dd64c4c9213510d2ce75fa85232934df" - integrity sha512-ZUXPF0WNnvs7AxoQRijt+DW2jsXnWCk8ac28tTYzTpBNnOEIAw83A+pYkCXjTFdJHMTc+wUmwNr71Zy2TRjlWg== + version "3.0.2" + resolved "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.2.tgz#c49c902e17364fc724e050db5c1d2b298c6379d4" + integrity sha512-qC7prjoEYR2QEe6SmCVfB1x3rfcQtUr1n4x89+3e0wSTMQ/KYCyf+/RAA9n2tllkkNc6//JMUZePdFRiGIWfaQ== dependencies: "@types/node" "*" "@types/ssh2" "*" -"@types/dockerode@^3.2.1": +"@types/dockerode@^3.2.5", "@types/dockerode@^3.3.0": version "3.3.0" resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.0.tgz#d6afcc1de31e211ee900da3a48ef4f1a496cf05a" integrity sha512-3Mc0b2gnypJB8Gwmr+8UVPkwjpf4kg1gVxw8lAI4Y/EzpK50LixU1wBSPN9D+xqiw2Ubb02JO8oM0xpwzvi2mg== @@ -7378,16 +7395,16 @@ resolved "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.0.tgz#9541eec4ad6e3ec5633270a3a2b55d981edc44a9" integrity sha512-14t0v1ICYRtRVcHASzes0v/O+TIeASb8aD55cWF1PidtInhFWSXcmhzhHqGjUWf9SUq1w70cvd1cWKUULubAfQ== -"@types/js-yaml@^3.12.1": - version "3.12.5" - 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.1" resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.1.tgz#5544730b65a480b18ace6b6ce914e519cec2d43b" integrity sha512-xdOvNmXmrZqqPy3kuCQ+fz6wA0xU5pji9cd1nDrflWaAWtYLLGk5ykW0H6yg5TVyehHP1pfmuuSaZkhP+kspVA== +"@types/js-yaml@^4.0.1": + version "4.0.5" + resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz#738dd390a6ecc5442f35e7f03fa1431353f7e138" + integrity sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA== + "@types/jscodeshift@^0.11.0": version "0.11.0" resolved "https://registry.npmjs.org/@types/jscodeshift/-/jscodeshift-0.11.0.tgz#7224cf1a4d0383b4fb2694ffed52f57b45c3325b" @@ -7598,6 +7615,14 @@ "@types/node" "*" form-data "^3.0.0" +"@types/node-fetch@^2.5.12": + version "2.5.12" + resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.12.tgz#8a6f779b1d4e60b7a57fb6fd48d84fb545b9cc66" + integrity sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + "@types/node@*", "@types/node@>= 8", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^14.0.10", "@types/node@^14.14.31", "@types/node@^14.14.32": version "14.17.8" resolved "https://registry.npmjs.org/@types/node/-/node-14.17.8.tgz#813b73ab7d82ac06ddfd2458b13c88459a3b319f" @@ -8010,10 +8035,10 @@ "@types/node" "*" "@types/ssh2-streams" "*" -"@types/ssh2@^0.5.45": - version "0.5.46" - resolved "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.46.tgz#e12341a242aea0e98ac2dec89e039bf421fd3584" - integrity sha512-1pC8FHrMPYdkLoUOwTYYifnSEPzAFZRsp3JFC/vokQ+dRrVI+hDBwz0SNmQ3pL6h39OSZlPs0uCG7wKJkftnaA== +"@types/ssh2@^0.5.48": + version "0.5.48" + resolved "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.48.tgz#0d9e8654a76eaaf4cfeaeb88d74c4489cfcf7aea" + integrity sha512-cmQu0gp/6RtDXe1r2xXGgi0V0TeCdueDSRMEvBX8cTRT/sSREkUpgCYZLyh+iI8Ql+VNV8Az9toQoYa/IdgHbQ== dependencies: "@types/node" "*" "@types/ssh2-streams" "*" @@ -8292,6 +8317,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yargs@^16.0.0": + version "16.0.4" + resolved "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz#26aad98dd2c2a38e421086ea9ad42b9e51642977" + integrity sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw== + dependencies: + "@types/yargs-parser" "*" + "@types/yarnpkg__lockfile@^1.1.4": version "1.1.4" resolved "https://registry.npmjs.org/@types/yarnpkg__lockfile/-/yarnpkg__lockfile-1.1.4.tgz#445251eb00bd9c1e751f82c7c6bf4f714edfd464" @@ -8806,7 +8838,7 @@ add-stream@^1.0.0: resolved "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa" integrity sha1-anmQQ3ynNtXhKI25K9MmbV9csqo= -address@1.1.2, address@^1.0.1: +address@1.1.2, address@^1.0.1, address@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== @@ -8913,7 +8945,7 @@ ajv@^8.0.1: require-from-string "^2.0.2" uri-js "^4.2.2" -alphanum-sort@^1.0.0: +alphanum-sort@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= @@ -8986,7 +9018,7 @@ ansi-regex@^4.0.0, ansi-regex@^4.1.0: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== -ansi-regex@^5.0.0: +ansi-regex@^5.0.0, ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== @@ -9016,6 +9048,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: "@types/color-name" "^1.1.1" color-convert "^2.0.1" +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + ansi-to-html@^0.6.11: version "0.6.14" resolved "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.6.14.tgz#65fe6d08bba5dd9db33f44a20aec331e0010dad8" @@ -9028,7 +9065,7 @@ any-observable@^0.3.0: resolved "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== -any-promise@^1.0.0, any-promise@^1.1.0: +any-promise@^1.0.0: version "1.3.0" resolved "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= @@ -9337,6 +9374,11 @@ aria-query@^4.2.2: "@babel/runtime" "^7.10.2" "@babel/runtime-corejs3" "^7.10.2" +aria-query@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz#210c21aaf469613ee8c9a62c7f86525e058db52c" + integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg== + arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" @@ -9382,16 +9424,16 @@ 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, 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== +array-includes@^3.0.3, array-includes@^3.1.1, array-includes@^3.1.2, array-includes@^3.1.4: + version "3.1.4" + resolved "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz#f5b493162c760f3539631f005ba2bb46acb45ba9" + integrity sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw== dependencies: call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.18.0-next.2" + es-abstract "^1.19.1" get-intrinsic "^1.1.1" - is-string "^1.0.5" + is-string "^1.0.7" array-union@^1.0.1, array-union@^1.0.2: version "1.0.2" @@ -9423,15 +9465,14 @@ array.prototype.flat@^1.2.1, array.prototype.flat@^1.2.3: define-properties "^1.1.3" es-abstract "^1.17.0-next.1" -array.prototype.flatmap@^1.2.1, array.prototype.flatmap@^1.2.3, array.prototype.flatmap@^1.2.4: - version "1.2.4" - resolved "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.2.4.tgz#94cfd47cc1556ec0747d97f7c7738c58122004c9" - integrity sha512-r9Z0zYoxqHz60vvQbWEdXIEtCwHF0yxaWfno9qzXeNHvfyl3BZqygmGzb84dsubyaXLH4husF+NFgMSdpZhk2Q== +array.prototype.flatmap@^1.2.1, array.prototype.flatmap@^1.2.4, array.prototype.flatmap@^1.2.5: + version "1.2.5" + resolved "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.2.5.tgz#908dc82d8a406930fdf38598d51e7411d18d4446" + integrity sha512-08u6rVyi1Lj7oqWbS9nUxliETrtIROT4XGTA4D/LWGten6E3ocm7cy9SIrmNHOL5XVbVuckUp3X6Xyg8/zpvHA== dependencies: call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.18.0-next.1" - function-bind "^1.1.1" + es-abstract "^1.19.0" array.prototype.map@^1.0.1: version "1.0.2" @@ -9467,7 +9508,7 @@ asn1.js@^4.0.0: inherits "^2.0.1" minimalistic-assert "^1.0.0" -asn1@^0.2.4, asn1@~0.2.0, asn1@~0.2.3: +asn1@^0.2.4, asn1@~0.2.3: version "0.2.4" resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== @@ -10363,7 +10404,7 @@ browserslist@4.14.2: escalade "^3.0.2" node-releases "^1.1.61" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.6: +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.0, browserslist@^4.16.5, browserslist@^4.16.6: version "4.18.1" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.18.1.tgz#60d3920f25b6860eb917c6c7b185576f4d8b017f" integrity sha512-8ScCzdpPwR2wQh8IT82CA2VgDwjHyqMovPBZSNH54+tm4Jk2pCuv90gmAdH6J84OCRWi0b4gMe6O6XPXuJnjgQ== @@ -10645,25 +10686,6 @@ call-me-maybe@^1.0.1: resolved "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" integrity sha1-JtII6onje1y95gJQoV8DHBak1ms= -caller-callsite@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" - integrity sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ= - dependencies: - callsites "^2.0.0" - -caller-path@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" - integrity sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ= - dependencies: - caller-callsite "^2.0.0" - -callsites@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" - integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= - callsites@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -11307,7 +11329,7 @@ color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.5.2, color-string@^1.5.4, color-string@^1.6.0: +color-string@^1.5.2, color-string@^1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312" integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA== @@ -11323,14 +11345,6 @@ color@3.0.x: color-convert "^1.9.1" color-string "^1.5.2" -color@^3.0.0: - version "3.1.3" - resolved "https://registry.npmjs.org/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e" - integrity sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ== - dependencies: - color-convert "^1.9.1" - color-string "^1.5.4" - color@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/color/-/color-4.0.1.tgz#21df44cd10245a91b1ccf5ba031609b0e10e7d67" @@ -11339,6 +11353,11 @@ color@^4.0.1: color-convert "^2.0.1" color-string "^1.6.0" +colord@^2.9.1: + version "2.9.1" + resolved "https://registry.npmjs.org/colord/-/colord-2.9.1.tgz#c961ea0efeb57c9f0f4834458f26cb9cc4a3f90e" + integrity sha512-4LBMSt09vR0uLnPVkOUBnmxgoaeN4ewRbx801wY/bXcltXfpR/G46OdWn96XpYmCWuYvO46aBZP4NgX8HpNAcw== + colorette@1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" @@ -11853,16 +11872,6 @@ cosmiconfig@7.0.0, cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" -cosmiconfig@^5.0.0: - version "5.2.1" - resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" - integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA== - dependencies: - import-fresh "^2.0.0" - is-directory "^0.3.1" - js-yaml "^3.13.1" - parse-json "^4.0.0" - cosmiconfig@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" @@ -11884,6 +11893,13 @@ cp-file@^7.0.0: nested-error-stacks "^2.0.0" p-event "^4.1.0" +cpu-features@0.0.2: + version "0.0.2" + resolved "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.2.tgz#9f636156f1155fd04bdbaa028bb3c2fbef3cea7a" + integrity sha512-/2yieBqvMcRj8McNzkycjW2v3OIUOibBfd2dLEJ0nWts8NobAxwiyw9phVNS6oDL8x8tz9F7uNVFEVpJncQpeA== + dependencies: + nan "^2.14.1" + cpy@^8.1.1: version "8.1.1" resolved "https://registry.npmjs.org/cpy/-/cpy-8.1.1.tgz#066ed4c6eaeed9577df96dae4db9438c1a90df62" @@ -11959,6 +11975,11 @@ create-require@^1.1.0: resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cronstrue@^1.122.0: + version "1.122.0" + resolved "https://registry.npmjs.org/cronstrue/-/cronstrue-1.122.0.tgz#bd6838077b476d28f61d381398b47b8c3912a126" + integrity sha512-PFuhZd+iPQQ0AWTXIEYX+t3nFGzBrWxmTWUKJOrsGRewaBSLKZ4I1f8s2kryU75nNxgyugZgiGh2OJsCTA/XlA== + cross-env@^7.0.0: version "7.0.3" resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" @@ -12052,17 +12073,11 @@ css-color-converter@^2.0.0: 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" - integrity sha1-gIrcLnnPhHOAabZGyyDsJ762KeA= - -css-declaration-sorter@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-4.0.1.tgz#c198940f63a76d7e36c1e71018b001721054cb22" - integrity sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA== +css-declaration-sorter@^6.0.3: + version "6.1.3" + resolved "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.1.3.tgz#e9852e4cf940ba79f509d9425b137d1f94438dc2" + integrity sha512-SvjQjNRZgh4ULK1LDJ2AduPKUKxIqmtU7ZAyi47BTV+M90Qvxr9AB6lKlLbDUfXqI9IQeYA8LbAsCZPpJEV3aA== dependencies: - postcss "^7.0.1" timsort "^0.3.0" css-in-js-utils@^2.0.0: @@ -12150,6 +12165,14 @@ css-tree@^1.1.2: mdn-data "2.0.14" source-map "^0.6.1" +css-tree@^1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" + integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== + 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" @@ -12197,73 +12220,55 @@ cssfilter@0.0.10: resolved "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae" integrity sha1-xtJnJjKi5cg+AT5oZKQs6N79IK4= -cssnano-preset-default@^4.0.7: - version "4.0.7" - resolved "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76" - integrity sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA== +cssnano-preset-default@^5.1.7: + version "5.1.7" + resolved "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.1.7.tgz#68c3ad1ec6a810482ec7d06b2d70fc34b6b0d70c" + integrity sha512-bWDjtTY+BOqrqBtsSQIbN0RLGD2Yr2CnecpP0ydHNafh9ZUEre8c8VYTaH9FEbyOt0eIfEUAYYk5zj92ioO8LA== dependencies: - css-declaration-sorter "^4.0.1" - cssnano-util-raw-cache "^4.0.1" - postcss "^7.0.0" - postcss-calc "^7.0.1" - postcss-colormin "^4.0.3" - postcss-convert-values "^4.0.1" - postcss-discard-comments "^4.0.2" - postcss-discard-duplicates "^4.0.2" - postcss-discard-empty "^4.0.1" - postcss-discard-overridden "^4.0.1" - postcss-merge-longhand "^4.0.11" - postcss-merge-rules "^4.0.3" - postcss-minify-font-values "^4.0.2" - postcss-minify-gradients "^4.0.2" - postcss-minify-params "^4.0.2" - postcss-minify-selectors "^4.0.2" - postcss-normalize-charset "^4.0.1" - postcss-normalize-display-values "^4.0.2" - postcss-normalize-positions "^4.0.2" - postcss-normalize-repeat-style "^4.0.2" - postcss-normalize-string "^4.0.2" - postcss-normalize-timing-functions "^4.0.2" - postcss-normalize-unicode "^4.0.1" - postcss-normalize-url "^4.0.1" - postcss-normalize-whitespace "^4.0.2" - postcss-ordered-values "^4.1.2" - postcss-reduce-initial "^4.0.3" - postcss-reduce-transforms "^4.0.2" - postcss-svgo "^4.0.2" - postcss-unique-selectors "^4.0.1" + css-declaration-sorter "^6.0.3" + cssnano-utils "^2.0.1" + postcss-calc "^8.0.0" + postcss-colormin "^5.2.1" + postcss-convert-values "^5.0.2" + postcss-discard-comments "^5.0.1" + postcss-discard-duplicates "^5.0.1" + postcss-discard-empty "^5.0.1" + postcss-discard-overridden "^5.0.1" + postcss-merge-longhand "^5.0.4" + postcss-merge-rules "^5.0.3" + postcss-minify-font-values "^5.0.1" + postcss-minify-gradients "^5.0.3" + postcss-minify-params "^5.0.2" + postcss-minify-selectors "^5.1.0" + postcss-normalize-charset "^5.0.1" + postcss-normalize-display-values "^5.0.1" + postcss-normalize-positions "^5.0.1" + postcss-normalize-repeat-style "^5.0.1" + postcss-normalize-string "^5.0.1" + postcss-normalize-timing-functions "^5.0.1" + postcss-normalize-unicode "^5.0.1" + postcss-normalize-url "^5.0.3" + postcss-normalize-whitespace "^5.0.1" + postcss-ordered-values "^5.0.2" + postcss-reduce-initial "^5.0.1" + postcss-reduce-transforms "^5.0.1" + postcss-svgo "^5.0.3" + postcss-unique-selectors "^5.0.2" -cssnano-util-get-arguments@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/cssnano-util-get-arguments/-/cssnano-util-get-arguments-4.0.0.tgz#ed3a08299f21d75741b20f3b81f194ed49cc150f" - integrity sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8= +cssnano-utils@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-2.0.1.tgz#8660aa2b37ed869d2e2f22918196a9a8b6498ce2" + integrity sha512-i8vLRZTnEH9ubIyfdZCAdIdgnHAUeQeByEeQ2I7oTilvP9oHO6RScpeq3GsFUVqeB8uZgOQ9pw8utofNn32hhQ== -cssnano-util-get-match@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/cssnano-util-get-match/-/cssnano-util-get-match-4.0.0.tgz#c0e4ca07f5386bb17ec5e52250b4f5961365156d" - integrity sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0= - -cssnano-util-raw-cache@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/cssnano-util-raw-cache/-/cssnano-util-raw-cache-4.0.1.tgz#b26d5fd5f72a11dfe7a7846fb4c67260f96bf282" - integrity sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA== +cssnano@^5.0.1: + version "5.0.11" + resolved "https://registry.npmjs.org/cssnano/-/cssnano-5.0.11.tgz#743397a05e04cb87e9df44b7659850adfafc3646" + integrity sha512-5SHM31NAAe29jvy0MJqK40zZ/8dGlnlzcfHKw00bWMVFp8LWqtuyPSFwbaoIoxvt71KWJOfg8HMRGrBR3PExCg== dependencies: - postcss "^7.0.0" - -cssnano-util-same-parent@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz#574082fb2859d2db433855835d9a8456ea18bbf3" - integrity sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q== - -cssnano@^4.1.10: - version "4.1.10" - resolved "https://registry.npmjs.org/cssnano/-/cssnano-4.1.10.tgz#0ac41f0b13d13d465487e111b778d42da631b8b2" - integrity sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ== - dependencies: - cosmiconfig "^5.0.0" - cssnano-preset-default "^4.0.7" - is-resolvable "^1.0.0" - postcss "^7.0.0" + cssnano-preset-default "^5.1.7" + is-resolvable "^1.1.0" + lilconfig "^2.0.3" + yaml "^1.10.2" csso@^4.0.2: version "4.0.2" @@ -12272,6 +12277,13 @@ csso@^4.0.2: dependencies: css-tree "1.0.0-alpha.37" +csso@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" + integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== + dependencies: + css-tree "^1.1.2" + cssom@^0.4.4: version "0.4.4" resolved "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" @@ -12974,7 +12986,7 @@ detect-node@^2.0.4: resolved "https://registry.npmjs.org/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw== -detect-port-alt@1.1.6: +detect-port-alt@1.1.6, detect-port-alt@^1.1.6: version "1.1.6" resolved "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz#24707deabe932d4a3cf621302027c2b266568275" integrity sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q== @@ -13078,27 +13090,27 @@ dns-txt@^2.0.2: dependencies: buffer-indexof "^1.0.0" -docker-compose@^0.23.10: - version "0.23.12" - resolved "https://registry.npmjs.org/docker-compose/-/docker-compose-0.23.12.tgz#fa883b98be08f6926143d06bf9e522ef7ed3210c" - integrity sha512-KFbSMqQBuHjTGZGmYDOCO0L4SaML3BsWTId5oSUyaBa22vALuFHNv+UdDWs3HcMylHWKsxCbLB7hnM/nCosWZw== +docker-compose@^0.23.13: + version "0.23.13" + resolved "https://registry.npmjs.org/docker-compose/-/docker-compose-0.23.13.tgz#77d37bd05b6a966345f631e6d05e961c79514f06" + integrity sha512-/9fYC4g3AO+qsqxIZhmbVnFvJJPcYEV2yJbAPPXH+6AytU3urIY8lUAXOlvY8sl4u25pdKu1JrOfAmWC7lJDJg== dependencies: yaml "^1.10.2" docker-modem@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/docker-modem/-/docker-modem-3.0.0.tgz#cb912ad8daed42f858269fb3be6944df281ec12d" - integrity sha512-WwFajJ8I5geZ/dDZ5FDMDA6TBkWa76xWwGIGw8uzUjNUGCN0to83wJ8Oi1AxrJTC0JBn+7fvIxUctnawtlwXeg== + version "3.0.3" + resolved "https://registry.npmjs.org/docker-modem/-/docker-modem-3.0.3.tgz#ac4bb1f32f81ac2e7120c5e99a068fab2458a32f" + integrity sha512-Tgkn2a+yiNP9FoZgMa/D9Wk+D2Db///0KOyKSYZRJa8w4+DzKyzQMkczKSdR/adQ0x46BOpeNkoyEOKjPhCzjw== dependencies: debug "^4.1.1" readable-stream "^3.5.0" split-ca "^1.0.1" - ssh2 "^0.8.7" + ssh2 "^1.4.0" -dockerode@^3.2.1: - version "3.3.0" - resolved "https://registry.npmjs.org/dockerode/-/dockerode-3.3.0.tgz#bedaf48ef9fa9124275a54a9881a92374c51008e" - integrity sha512-St08lfOjpYCOXEM8XA0VLu3B3hRjtddODphNW5GFoA0AS3JHgoPQKOz0Qmdzg3P+hUPxhb02g1o1Cu1G+U3lRg== +dockerode@^3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/dockerode/-/dockerode-3.3.1.tgz#74f66e239e092e7910e2beae6322d35c44b08cdc" + integrity sha512-AS2mr8Lp122aa5n6d99HkuTNdRV1wkkhHwBdcnY6V0+28D3DSYwhxAk85/mM9XwD3RMliTxyr63iuvn5ZblFYQ== dependencies: docker-modem "^3.0.0" tar-fs "~2.0.1" @@ -13130,6 +13142,11 @@ dom-accessibility-api@^0.5.4, dom-accessibility-api@^0.5.6: resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.6.tgz#3f5d43b52c7a3bd68b5fb63fa47b4e4c1fdf65a9" integrity sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw== +dom-accessibility-api@^0.5.9: + version "0.5.10" + resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz#caa6d08f60388d0bb4539dd75fe458a9a1d0014c" + integrity sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g== + dom-converter@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" @@ -13592,7 +13609,7 @@ error@^10.4.0: resolved "https://registry.npmjs.org/error/-/error-10.4.0.tgz#6fcf0fd64bceb1e750f8ed9a3dd880f00e46a487" integrity sha512-YxIFEJuhgcICugOUvRx5th0UM+ActZ9sjY0QJmeVwsQdvosZ7kYzc9QqS0Da3R5iUmgU5meGIxh0xBeZpMVeLw== -es-abstract@^1.17.0, es-abstract@^1.17.0-next.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.4, es-abstract@^1.17.5, es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2: +es-abstract@^1.17.0-next.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.4, es-abstract@^1.18.0-next.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== @@ -13614,6 +13631,32 @@ es-abstract@^1.17.0, es-abstract@^1.17.0-next.0, es-abstract@^1.17.0-next.1, es- string.prototype.trimstart "^1.0.4" unbox-primitive "^1.0.0" +es-abstract@^1.19.0, es-abstract@^1.19.1: + version "1.19.1" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz#d4885796876916959de78edaa0df456627115ec3" + integrity sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w== + dependencies: + call-bind "^1.0.2" + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + get-intrinsic "^1.1.1" + get-symbol-description "^1.0.0" + has "^1.0.3" + has-symbols "^1.0.2" + internal-slot "^1.0.3" + is-callable "^1.2.4" + is-negative-zero "^2.0.1" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.1" + is-string "^1.0.7" + is-weakref "^1.0.1" + object-inspect "^1.11.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.1" + 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" @@ -13692,10 +13735,113 @@ es6-weak-map@^2.0.3: es6-iterator "^2.0.3" es6-symbol "^3.1.1" -esbuild@^0.8.56: - version "0.8.57" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.8.57.tgz#a42d02bc2b57c70bcd0ef897fe244766bb6dd926" - integrity sha512-j02SFrUwFTRUqiY0Kjplwjm1psuzO1d6AjaXKuOR9hrY0HuPsT6sV42B6myW34h1q4CRy+Y3g4RU/cGJeI/nNA== +esbuild-android-arm64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.1.tgz#470b99c1c4b49f33fd0a20ed153b15008173fd63" + integrity sha512-elQd3hTg93nU2GQ5PPCDAFe5+utxZX96RG8RixqIPxf8pzmyIzcpKG76L/9FabPf3LT1z+nLF1sajCU8eVRDyg== + +esbuild-darwin-64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.1.tgz#135f48f299f2ce3eb3ca1b1f3ec03d81108ab79e" + integrity sha512-PR3HZgbPRwsQbbOR1fJrfkt/Cs0JDyI3yzOKg2PPWk0H1AseZDBqPUY9b/0+BIjFwA5Jz/aAiq832hppsuJtNw== + +esbuild-darwin-arm64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.1.tgz#7117a857bac99ece28ebba859a47dce47f565f9f" + integrity sha512-/fiSSOkOEa3co6yYtwgXouz8jZrG0qnXPEKiktFf2BQE8NON3ARTw43ZegaH+xMRFNgYBJEOOZIdzI3sIFEAxw== + +esbuild-freebsd-64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.1.tgz#2b7ca5ec572f2800b1ec88988affc4482c5ac4b7" + integrity sha512-ZJV+nfa8E8PdXnRc05PO3YMfgSj7Ko+kdHyGDE6OaNo1cO8ZyfacqLaWkY35shDDaeacklhD8ZR4qq5nbJKX1A== + +esbuild-freebsd-arm64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.1.tgz#63e8b77643ea8270d878cfab7dd9201a114f20fb" + integrity sha512-6N9zTD+SecJr2g9Ohl9C10WIk5FpQ+52bNamRy0sJoHwP31G5ObzKzq8jAtg1Jeggpu6P8auz3P/UL+3YioSwQ== + +esbuild-linux-32@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.1.tgz#f00ae7f12d2abc0dc37e2a7e7c7c29764da87093" + integrity sha512-RtPgE6e7WefbAxRjVryisKFJ0nUwR2DMjwmYW/a1a0F1+Ge6FR+RqvgiY0DrM9TtxSUU0eryDXNF4n3UfxX3mg== + +esbuild-linux-64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.1.tgz#2ee9dd76be1185abb1e967052e3b6ab16a1d3da4" + integrity sha512-JpxM0ar6Z+2v3vfFrxP7bFb8Wzb6gcGL9MxRqAJplDfGnee8HbfPge6svaazXeX9XJceeEqwxwWGB0qyCcxo7A== + +esbuild-linux-arm64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.1.tgz#601e855b78e0636e120771296b43eb4f7d68a314" + integrity sha512-cFbeZf171bIf+PPLlQDBzagK85lCCxxVdMV1IVUA96Y3kvEgqcy2n9mha+QE1M/T+lIOPDsmLRgH1XqMFwLTSg== + +esbuild-linux-arm@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.1.tgz#c0d364a20f12a653bdd2f41436788b99502dc287" + integrity sha512-eBRHexCijAYWzcvQLGHxyxIlYOkYhXvcb/O7HvzJfCAVWCnTx9TxxYJ3UppBC6dDFbAq4HwKhskvmesQdKMeBg== + +esbuild-linux-mips64le@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.1.tgz#a5f6e9c6e7950a3fad08bb3653bc3f5d71b4e249" + integrity sha512-UGb+sqHkL7wOQFLH0RoFhcRAlJNqbqs6GtJd1It5jJ2juOGqAkCv8V12aGDX9oRB6a+Om7cdHcH+6AMZ+qlaww== + +esbuild-linux-ppc64le@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.1.tgz#762cec24cf5afeee3f805a4679a3f5e29702173a" + integrity sha512-LIHGkGdy9wYlmkkoVHm6feWhkoi4VBXDiEVyNjXEhlzsBcP/CaRy+B8IJulzaU1ALLiGcsCQ2MC5UbFn/iTvmA== + +esbuild-netbsd-64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.1.tgz#66ec7ac0b3eeb84f8c1ac27eecf16f59d93706a8" + integrity sha512-TWc1QIgtPwaK5nC1GT2ASTuy/CJhNKHN4h5PJRP1186VfI+k2uvXakS7bqO/M26F6jAMy8jDeCtilacqpwsvfA== + +esbuild-openbsd-64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.1.tgz#7515049bc7032ca2fb6811dc260f5ec9e1d9fe65" + integrity sha512-Z9/Zb77K+pK9s7mAsvwS56K8tCbLvNZ9UI4QVJSYqDgOmmDJOBT4owWnCqZ5cJI+2y4/F9KwCpFFTNUdPglPKA== + +esbuild-sunos-64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.1.tgz#795f6bc7ce8c5177afb65f8d6c161a02f0c3e125" + integrity sha512-c4sF8146kNW8529wfkB6vO0ZqPgokyS2hORqKa4p/QKZdp+xrF2NPmvX5aN+Zt14oe6wVZuhYo6LGv7V4Gg04g== + +esbuild-windows-32@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.1.tgz#ffffa6378733eeaa23ed5cfe539e2fbe1e635ef6" + integrity sha512-XP8yElaJtLGGjH7D72t5IWtP0jmc1Jqm4IjQARB17l0LTJO/n+N2X64rDWePJv6qimYxa5p2vTjkZc5v+YZTSQ== + +esbuild-windows-64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.1.tgz#46f3b4a90f937a8ad6456cd70478ebfc6771814f" + integrity sha512-fe+ShdyfiuGcCEdVKW//6MaM4MwikiWBWSBn8mebNAbjRqicH0injDOFVI7aUovAfrEt7+FGkf402s//hi0BVg== + +esbuild-windows-arm64@0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.1.tgz#c7067389d28139e6a18db1996178c3a3e07a22b3" + integrity sha512-wBVakhcIzQ3NZ33DFM6TjIObXPHaXOsqzvPwefXHvwBSC/N/e/g6fBeM7N/Moj3AmxLjKaB+vePvTGdxk6RPCg== + +esbuild@^0.14.1: + version "0.14.1" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.14.1.tgz#b834da3aa5858073205a6d4f948ffde0d650e4e3" + integrity sha512-J/LhUwELcmz0+CJfiaKzu7Rnj9ffWFLvMx+dKvdOfg+fQmoP6q9glla26LCm9BxpnPUjXChHeubLiMlKab/PYg== + optionalDependencies: + esbuild-android-arm64 "0.14.1" + esbuild-darwin-64 "0.14.1" + esbuild-darwin-arm64 "0.14.1" + esbuild-freebsd-64 "0.14.1" + esbuild-freebsd-arm64 "0.14.1" + esbuild-linux-32 "0.14.1" + esbuild-linux-64 "0.14.1" + esbuild-linux-arm "0.14.1" + esbuild-linux-arm64 "0.14.1" + esbuild-linux-mips64le "0.14.1" + esbuild-linux-ppc64le "0.14.1" + esbuild-netbsd-64 "0.14.1" + esbuild-openbsd-64 "0.14.1" + esbuild-sunos-64 "0.14.1" + esbuild-windows-32 "0.14.1" + esbuild-windows-64 "0.14.1" + esbuild-windows-arm64 "0.14.1" escalade@^3.0.2, escalade@^3.1.1: version "3.1.1" @@ -13864,21 +14010,24 @@ eslint-plugin-react-hooks@^4.0.0: integrity sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ== eslint-plugin-react@^7.12.4: - version "7.22.0" - resolved "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.22.0.tgz#3d1c542d1d3169c45421c1215d9470e341707269" - integrity sha512-p30tuX3VS+NWv9nQot9xIGAHBXR0+xJVaZriEsHoJrASGCJZDJ8JLNM0YqKqI0AKm6Uxaa1VUHoNEibxRCMQHA== + version "7.27.1" + resolved "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.27.1.tgz#469202442506616f77a854d91babaae1ec174b45" + integrity sha512-meyunDjMMYeWr/4EBLTV1op3iSG3mjT/pz5gti38UzfM4OPpNc2m0t2xvKCOMU5D6FSdd34BIMFOvQbW+i8GAA== dependencies: - array-includes "^3.1.1" - array.prototype.flatmap "^1.2.3" + array-includes "^3.1.4" + array.prototype.flatmap "^1.2.5" doctrine "^2.1.0" - has "^1.0.3" + estraverse "^5.3.0" jsx-ast-utils "^2.4.1 || ^3.0.0" - object.entries "^1.1.2" - object.fromentries "^2.0.2" - object.values "^1.1.1" + minimatch "^3.0.4" + object.entries "^1.1.5" + object.fromentries "^2.0.5" + object.hasown "^1.1.0" + object.values "^1.1.5" prop-types "^15.7.2" - resolve "^1.18.1" - string.prototype.matchall "^4.0.2" + resolve "^2.0.0-next.3" + semver "^6.3.0" + string.prototype.matchall "^4.0.6" eslint-scope@5.1.1, eslint-scope@^5.1.1: version "5.1.1" @@ -14004,10 +14153,10 @@ estraverse@^4.1.1, estraverse@^4.2.0: resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== -estraverse@^5.1.0, estraverse@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" - integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== +estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: + version "5.3.0" + resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== estree-walker@^0.6.1: version "0.6.1" @@ -14644,6 +14793,11 @@ filesize@6.1.0: resolved "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00" integrity sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg== +filesize@^6.1.0: + version "6.4.0" + resolved "https://registry.npmjs.org/filesize/-/filesize-6.4.0.tgz#914f50471dd66fdca3cefe628bd0cde4ef769bcd" + integrity sha512-mjFIpOHC4jbfcTfoh4rkWpI31mF7viw9ikj/JyLoKzqlwG/YsefKfvYlYhdYdg/9mtK2z1AzgN/0LvVQ3zdlSQ== + fill-range@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" @@ -14878,6 +15032,25 @@ fork-ts-checker-webpack-plugin@^6.0.4: semver "^7.3.2" tapable "^1.0.0" +fork-ts-checker-webpack-plugin@^6.0.5: + version "6.4.2" + resolved "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.4.2.tgz#6d307fb4072ce4abe4d56a89c8ef060066f33d81" + integrity sha512-EqtzzRdx2mldr0KEydSN9jaNrf419gMpwkloumG6K/S7jtJc9Fl7wMJ+y+o7DLLGMMU/kouYr06agTD/YkxzIQ== + dependencies: + "@babel/code-frame" "^7.8.3" + "@types/json-schema" "^7.0.5" + chalk "^4.1.0" + chokidar "^3.4.2" + cosmiconfig "^6.0.0" + deepmerge "^4.2.2" + fs-extra "^9.0.0" + glob "^7.1.6" + memfs "^3.1.2" + minimatch "^3.0.4" + schema-utils "2.7.0" + semver "^7.3.2" + tapable "^1.0.0" + form-data-encoder@^1.4.3: version "1.6.0" resolved "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.6.0.tgz#9dd1f479836c1b1b47201667c68f8daafa800943" @@ -15067,7 +15240,7 @@ fsevents@^1.2.7: bindings "^1.5.0" nan "^2.12.1" -fsevents@^2.1.2, fsevents@~2.3.1, fsevents@~2.3.2: +fsevents@^2.1.2, fsevents@~2.3.2: version "2.3.2" resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -15191,7 +15364,7 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5: resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: +get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, 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== @@ -15263,6 +15436,14 @@ get-stream@^6.0.0: resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== +get-symbol-description@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" + integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.1" + get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" @@ -15430,6 +15611,18 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, gl once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + global-dirs@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201" @@ -15444,7 +15637,7 @@ global-dirs@^3.0.0: dependencies: ini "2.0.0" -global-modules@2.0.0: +global-modules@2.0.0, global-modules@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== @@ -15888,7 +16081,7 @@ gud@^1.0.0: resolved "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" integrity sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw== -gzip-size@5.1.1: +gzip-size@5.1.1, gzip-size@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274" integrity sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA== @@ -15943,7 +16136,7 @@ has-ansi@^2.0.0: dependencies: ansi-regex "^2.0.0" -has-bigints@^1.0.0: +has-bigints@^1.0.0, has-bigints@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== @@ -15970,6 +16163,13 @@ has-symbols@^1.0.0, has-symbols@^1.0.1, has-symbols@^1.0.2: resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== +has-tostringtag@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" + integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== + dependencies: + has-symbols "^1.0.2" + 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" @@ -16011,7 +16211,7 @@ has-yarn@^2.1.0: resolved "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== -has@^1.0.0, has@^1.0.3: +has@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== @@ -16135,11 +16335,6 @@ helmet@^4.0.0: resolved "https://registry.npmjs.org/helmet/-/helmet-4.4.1.tgz#a17e1444d81d7a83ddc6e6f9bc6e2055b994efe7" integrity sha512-G8tp0wUMI7i8wkMk2xLcEvESg5PiCitFMYgGRc/PwULB0RVhTP5GFdxOwvJwp9XVha8CuS8mnhmE8I/8dx/pbw== -hex-color-regex@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" - integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== - highlight.js@^10.1.0, highlight.js@^10.1.1, highlight.js@^10.4.1, highlight.js@^10.6.0: version "10.7.2" resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.2.tgz#89319b861edc66c48854ed1e6da21ea89f847360" @@ -16151,9 +16346,9 @@ highlight.js@~10.7.0: integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== history@^5.0.0: - version "5.0.1" - resolved "https://registry.npmjs.org/history/-/history-5.0.1.tgz#de35025ed08bce0db62364b47ebbf9d97b5eb06a" - integrity sha512-5qC/tFUKfVci5kzgRxZxN5Mf1CV8NmJx9ByaPX0YTLx5Vz3Svh7NYp6eA4CpDq4iA9D0C1t8BNIfvQIrUI3mVw== + version "5.1.0" + resolved "https://registry.npmjs.org/history/-/history-5.1.0.tgz#2e93c09c064194d38d52ed62afd0afc9d9b01ece" + integrity sha512-zPuQgPacm2vH2xdORvGGz1wQMuHSIB56yNAy5FnLuwOwgSYyPKptJtcMm6Ev+hRGeS+GzhbmRacHzvlESbFwDg== dependencies: "@babel/runtime" "^7.7.6" @@ -16212,21 +16407,6 @@ hpagent@^0.1.1: resolved "https://registry.npmjs.org/hpagent/-/hpagent-0.1.2.tgz#cab39c66d4df2d4377dbd212295d878deb9bdaa9" integrity sha512-ePqFXHtSQWAFXYmj+JtOTHr84iNrII4/QRlAAPPE+zqnKy4xJo7Ie1Y4kC7AdB+LxLxSTTzBMASsEcy0q8YyvQ== -hsl-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" - integrity sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4= - -hsla-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" - integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= - -html-comment-regex@^1.1.0: - version "1.1.2" - resolved "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" - integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== - html-encoding-sniffer@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" @@ -16435,6 +16615,15 @@ http-signature@~1.2.0: jsprim "^1.2.2" sshpk "^1.7.0" +http-signature@~1.3.6: + version "1.3.6" + resolved "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9" + integrity sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw== + dependencies: + assert-plus "^1.0.0" + jsprim "^2.0.2" + sshpk "^1.14.1" + http2-wrapper@^1.0.0-beta.5.2: version "1.0.0-beta.5.2" resolved "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.0-beta.5.2.tgz#8b923deb90144aea65cf834b016a340fc98556f3" @@ -16578,10 +16767,10 @@ immer@8.0.1: resolved "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== -immer@^9.0.1: - version "9.0.6" - resolved "https://registry.npmjs.org/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73" - integrity sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ== +immer@^9.0.1, immer@^9.0.6: + version "9.0.7" + resolved "https://registry.npmjs.org/immer/-/immer-9.0.7.tgz#b6156bd7db55db7abc73fd2fdadf4e579a701075" + integrity sha512-KGllzpbamZDvOIxnmJ0jI840g7Oikx58lBPWV0hUh7dtAyZpFqqrBZdKka5GlTwMTZ1Tjc/bKKW4VSFAt6BqMA== immutable@>=3.8.2, immutable@^3.8.2, immutable@^3.x.x: version "3.8.2" @@ -16600,14 +16789,6 @@ import-cwd@^3.0.0: dependencies: import-from "^3.0.0" -import-fresh@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" - integrity sha1-2BNVwVYS04bGH53dOSLUMEgipUY= - dependencies: - caller-path "^2.0.0" - resolve-from "^3.0.0" - import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: version "3.2.1" resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66" @@ -16813,14 +16994,14 @@ internal-ip@^6.2.0: is-ip "^3.1.0" p-event "^4.2.0" -internal-slot@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.2.tgz#9c2e9fb3cd8e5e4256c6f45fe310067fcfa378a3" - integrity sha512-2cQNfwhAfJIkU4KZPkDI+Gj5yNNnbqi40W9Gge6dfnk4TocEVm00B3bdiL+JINrbGJil2TeHvM4rETGzk/f/0g== +internal-slot@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" + integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA== dependencies: - es-abstract "^1.17.0-next.1" + get-intrinsic "^1.1.0" has "^1.0.3" - side-channel "^1.0.2" + side-channel "^1.0.4" interpret@^1.0.0: version "1.2.0" @@ -16877,10 +17058,10 @@ ipaddr.js@^2.0.1: resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0" integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng== -is-absolute-url@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" - integrity sha1-UFMN+4T8yap9vnhS6Do3uTufKqY= +is-absolute-url@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz#96c6a22b6a23929b11ea0afb1836c36ad4a5d698" + integrity sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q== is-absolute@^1.0.0: version "1.0.0" @@ -16986,6 +17167,11 @@ is-callable@^1.1.4, is-callable@^1.2.3: resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz#8b1e0500b73a1d76c70487636f368e519de8db8e" integrity sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ== +is-callable@^1.2.4: + version "1.2.4" + resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" + integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== + is-ci@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" @@ -17000,18 +17186,6 @@ is-ci@^3.0.0: dependencies: ci-info "^3.1.1" -is-color-stop@^1.0.0: - version "1.1.0" - resolved "https://registry.npmjs.org/is-color-stop/-/is-color-stop-1.1.0.tgz#cfff471aee4dd5c9e158598fbe12967b5cdad345" - integrity sha1-z/9HGu5N1cnhWFmPvhKWe1za00U= - dependencies: - css-color-names "^0.0.4" - hex-color-regex "^1.1.0" - hsl-regex "^1.0.0" - hsla-regex "^1.0.0" - rgb-regex "^1.0.1" - rgba-regex "^1.0.0" - is-core-module@^2.1.0, is-core-module@^2.2.0: version "2.4.0" resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz#8e9fc8e15027b011418026e98f0e6f4d86305cc1" @@ -17066,11 +17240,6 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2: is-data-descriptor "^1.0.0" kind-of "^6.0.2" -is-directory@^0.3.1: - version "0.3.1" - resolved "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" - integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= - is-docker@^2.0.0, is-docker@^2.1.1: version "2.2.1" resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" @@ -17372,6 +17541,14 @@ is-regex@^1.0.4, is-regex@^1.1.2: call-bind "^1.0.2" has-symbols "^1.0.1" +is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + is-regexp@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" @@ -17384,12 +17561,12 @@ is-relative@^1.0.0: dependencies: is-unc-path "^1.0.0" -is-resolvable@^1.0.0: +is-resolvable@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== -is-root@2.1.0: +is-root@2.1.0, is-root@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" integrity sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg== @@ -17406,6 +17583,11 @@ is-set@^2.0.1: resolved "https://registry.npmjs.org/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43" integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA== +is-shared-array-buffer@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6" + integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA== + is-ssh@^1.3.0: version "1.3.1" resolved "https://registry.npmjs.org/is-ssh/-/is-ssh-1.3.1.tgz#f349a8cadd24e65298037a522cf7520f2e81a0f3" @@ -17433,6 +17615,13 @@ is-string@^1.0.4, is-string@^1.0.5: resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6" integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ== +is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" + is-subdir@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-subdir/-/is-subdir-1.1.1.tgz#423e66902f9c5f159b9cc4826c820df083059538" @@ -17440,13 +17629,6 @@ is-subdir@^1.1.1: dependencies: better-path-resolve "1.0.0" -is-svg@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/is-svg/-/is-svg-3.0.0.tgz#9321dbd29c212e5ca99c4fa9794c714bcafa2f75" - integrity sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ== - dependencies: - html-comment-regex "^1.1.0" - is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" @@ -17501,6 +17683,13 @@ is-utf8@^0.2.0, is-utf8@^0.2.1: resolved "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= +is-weakref@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.1.tgz#842dba4ec17fa9ac9850df2d6efbc1737274f2a2" + integrity sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ== + dependencies: + call-bind "^1.0.0" + is-whitespace-character@^1.0.0: version "1.0.4" resolved "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz#0858edd94a95594c7c9dd0b5c174ec6e45ee4aa7" @@ -18182,10 +18371,10 @@ jose@^2.0.5: dependencies: "@panva/asn1.js" "^1.0.0" -joycon@^2.2.5: - version "2.2.5" - resolved "https://registry.npmjs.org/joycon/-/joycon-2.2.5.tgz#8d4cf4cbb2544d7b7583c216fcdfec19f6be1615" - integrity sha512-YqvUxoOcVPnCp0VU1/56f+iKSdvIRJYPznH22BdXV3xMk75SFXhWeJkZ8C9XxUWt1b5x2X1SxuFygW1U0FmkEQ== +joycon@^3.0.1: + version "3.1.0" + resolved "https://registry.npmjs.org/joycon/-/joycon-3.1.0.tgz#33bb2b6b5a6849a1e251bed623bdf610f477d49f" + integrity sha512-5Y/YJghKF/IzaUXTut0JtbQyHfBShTaIsH7hHhGXEzYO07zWdWZm5hr3Q6miqhrwsRqqm3mgOnUEZdn+1aRxKQ== js-base64@^3.6.0: version "3.7.2" @@ -18410,7 +18599,7 @@ json-schema@0.2.3: resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= -json-schema@^0.4.0: +json-schema@0.4.0, json-schema@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== @@ -18472,6 +18661,11 @@ json5@^2.1.2, json5@^2.1.3: dependencies: minimist "^1.2.5" +jsonc-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz#abdd785701c7e7eaca8a9ec8cf070ca51a745a22" + integrity sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA== + jsonfile@^2.1.0: version "2.4.0" resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" @@ -18515,10 +18709,10 @@ jsonpath-plus@^5.0.7: resolved "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-5.1.0.tgz#2fc4b2e461950626c98525425a3a3518b85af6c3" integrity sha512-890w2Pjtj0iswAxalRlt2kHthi6HKrXEfZcn+ZNZptv7F3rUGIeDuZo+C+h4vXBHLEsVjJrHeCm35nYeZLzSBQ== -jsonpointer@^4.0.1: - version "4.1.0" - resolved "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.1.0.tgz#501fb89986a2389765ba09e6053299ceb4f2c2cc" - integrity sha512-CXcRvMyTlnR53xMcKnuMzfCA5i/nfblTnnr74CZb6C4vG39eu6w51t7nKmU5MfLfbTgGItliNyjO/ciNPDqClg== +jsonpointer@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.0.tgz#f802669a524ec4805fa7389eadbc9921d5dc8072" + integrity sha512-PNYZIdMjVIvVgDSYKTT63Y+KZ6IZvGRNNWcxwD+GNnUz1MKPfv30J8ueCjdwcN0nDx2SlshgyB7Oy0epAzVRRg== jsonschema@^1.2.6: version "1.4.0" @@ -18551,6 +18745,16 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jsprim@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d" + integrity sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ== + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.4.0" + verror "1.10.0" + jss-plugin-camel-case@^10.5.1: version "10.6.0" resolved "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.6.0.tgz#93d2cd704bf0c4af70cc40fb52d74b8a2554b170" @@ -18969,6 +19173,11 @@ libnpmpublish@^4.0.0: semver "^7.1.3" ssri "^8.0.0" +lilconfig@^2.0.3: + version "2.0.4" + resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.4.tgz#f4507d043d7058b380b6a8f5cb7bcd4b34cee082" + integrity sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA== + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -20515,6 +20724,11 @@ mime@^2.2.0, mime@^2.4.4, mime@^2.4.6: resolved "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" @@ -20711,10 +20925,10 @@ mixin-object@^2.0.1: for-in "^0.1.3" is-extendable "^0.1.1" -mixme@^0.3.1: - version "0.3.5" - resolved "https://registry.npmjs.org/mixme/-/mixme-0.3.5.tgz#304652cdaf24a3df0487205e61ac6162c6906ddd" - integrity sha512-SyV9uPETRig5ZmYev0ANfiGeB+g6N2EnqqEfBbCGmmJ6MgZ3E4qv5aPbnHVdZ60KAHHXV+T3sXopdrnIXQdmjQ== +mixme@^0.5.1: + version "0.5.4" + resolved "https://registry.npmjs.org/mixme/-/mixme-0.5.4.tgz#8cb3bd0cd32a513c161bf1ca99d143f0bcf2eff3" + integrity sha512-3KYa4m4Vlqx98GPdOHghxSdNtTvcP8E0kkaJ5Dlh+h2DRzF7zpuVVcA8B0QpKd11YJeP9QQ7ASkKzOeu195Wzw== mkdirp-classic@^0.5.2: version "0.5.2" @@ -20928,6 +21142,11 @@ nan@^2.12.1, nan@^2.14.0: resolved "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== +nan@^2.14.1, nan@^2.15.0: + version "2.15.0" + resolved "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" + integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== + nano-css@^5.1.0, nano-css@^5.3.1: version "5.3.1" resolved "https://registry.npmjs.org/nano-css/-/nano-css-5.3.1.tgz#b709383e07ad3be61f64edffacb9d98250b87a1f" @@ -21097,13 +21316,6 @@ node-fetch@2.6.1, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1: resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-fetch@2.6.5: - version "2.6.5" - resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.5.tgz#42735537d7f080a7e5f78b6c549b7146be1742fd" - integrity sha512-mmlIVHJEu5rnIxgEgez6b9GgWXbkZj5YZ7fx+2r94a2E+Uirsp6HsPTPlomfdHtpt/B0cdKviwkoaM6pyvUOpQ== - dependencies: - whatwg-url "^5.0.0" - node-forge@^0.10.0: version "0.10.0" resolved "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" @@ -21324,7 +21536,7 @@ normalize-range@^0.1.2: resolved "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= -normalize-url@^3.0.0, normalize-url@^3.3.0: +normalize-url@^3.3.0: version "3.3.0" resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== @@ -21334,6 +21546,11 @@ normalize-url@^4.1.0: resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== +normalize-url@^6.0.1: + version "6.1.0" + resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" + integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== + npm-bundled@^1.0.1, npm-bundled@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b" @@ -21550,6 +21767,11 @@ object-hash@^2.0.1, object-hash@^2.1.1: resolved "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== +object-inspect@^1.11.0: + version "1.11.0" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1" + integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg== + object-inspect@^1.9.0: version "1.9.0" resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" @@ -21587,24 +21809,23 @@ object.assign@^4.1.0, object.assign@^4.1.2: has-symbols "^1.0.1" object-keys "^1.1.1" -object.entries@^1.1.0, object.entries@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/object.entries/-/object.entries-1.1.2.tgz#bc73f00acb6b6bb16c203434b10f9a7e797d3add" - integrity sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA== +object.entries@^1.1.0, object.entries@^1.1.5: + version "1.1.5" + resolved "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz#e1acdd17c4de2cd96d5a08487cfb9db84d881861" + integrity sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.17.5" - has "^1.0.3" + es-abstract "^1.19.1" -"object.fromentries@^2.0.0 || ^1.0.0", object.fromentries@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.2.tgz#4a09c9b9bb3843dd0f89acdb517a794d4f355ac9" - integrity sha512-r3ZiBH7MQppDJVLx6fhD618GKNG40CZYH9wgwdhKxBDDbQgjeWGGd4AtkZad84d291YxvWe7bJGuE65Anh0dxQ== +"object.fromentries@^2.0.0 || ^1.0.0", object.fromentries@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz#7b37b205109c21e741e605727fe8b0ad5fa08251" + integrity sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - function-bind "^1.1.1" - has "^1.0.3" + es-abstract "^1.19.1" object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0: version "2.1.0" @@ -21614,6 +21835,14 @@ object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0 define-properties "^1.1.3" es-abstract "^1.17.0-next.1" +object.hasown@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.0.tgz#7232ed266f34d197d15cac5880232f7a4790afe5" + integrity sha512-MhjYRfj3GBlhSkDHo6QmvgjRLXQ2zndabdf3nX0yTyZK9rPfxb6uRpAac8HXNLy1GpqWtZ81Qh4v3uOls2sRAg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.19.1" + object.pick@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" @@ -21621,15 +21850,14 @@ object.pick@^1.3.0: dependencies: isobject "^3.0.1" -object.values@^1.1.0, object.values@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e" - integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA== +object.values@^1.1.0, object.values@^1.1.1, object.values@^1.1.5: + version "1.1.5" + resolved "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac" + integrity sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - function-bind "^1.1.1" - has "^1.0.3" + es-abstract "^1.19.1" objectorarray@^1.0.4: version "1.0.4" @@ -22726,7 +22954,7 @@ pkg-dir@^5.0.0: dependencies: find-up "^5.0.0" -pkg-up@3.1.0: +pkg-up@3.1.0, pkg-up@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== @@ -22783,61 +23011,50 @@ posix-character-classes@^0.1.0: resolved "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= -postcss-calc@^7.0.1: - version "7.0.2" - resolved "https://registry.npmjs.org/postcss-calc/-/postcss-calc-7.0.2.tgz#504efcd008ca0273120568b0792b16cdcde8aac1" - integrity sha512-rofZFHUg6ZIrvRwPeFktv06GdbDYLcGqh9EwiMutZg+a0oePCCw1zHOEiji6LCpyRcjTREtPASuUqeAvYlEVvQ== +postcss-calc@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.0.0.tgz#a05b87aacd132740a5db09462a3612453e5df90a" + integrity sha512-5NglwDrcbiy8XXfPM11F3HeC6hoT9W7GUH/Zi5U/p7u3Irv4rHhdDcIZwG0llHXV4ftsBjpfWMXAnXNl4lnt8g== dependencies: - postcss "^7.0.27" postcss-selector-parser "^6.0.2" postcss-value-parser "^4.0.2" -postcss-colormin@^4.0.3: - version "4.0.3" - resolved "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-4.0.3.tgz#ae060bce93ed794ac71264f08132d550956bd381" - integrity sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw== +postcss-colormin@^5.2.1: + version "5.2.1" + resolved "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.2.1.tgz#6e444a806fd3c578827dbad022762df19334414d" + integrity sha512-VVwMrEYLcHYePUYV99Ymuoi7WhKrMGy/V9/kTS0DkCoJYmmjdOMneyhzYUxcNgteKDVbrewOkSM7Wje/MFwxzA== dependencies: - browserslist "^4.0.0" - color "^3.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + browserslist "^4.16.6" + caniuse-api "^3.0.0" + colord "^2.9.1" + postcss-value-parser "^4.1.0" -postcss-convert-values@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-4.0.1.tgz#ca3813ed4da0f812f9d43703584e449ebe189a7f" - integrity sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ== +postcss-convert-values@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.0.2.tgz#879b849dc3677c7d6bc94b6a2c1a3f0808798059" + integrity sha512-KQ04E2yadmfa1LqXm7UIDwW1ftxU/QWZmz6NKnHnUvJ3LEYbbcX6i329f/ig+WnEByHegulocXrECaZGLpL8Zg== dependencies: - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + postcss-value-parser "^4.1.0" -postcss-discard-comments@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-4.0.2.tgz#1fbabd2c246bff6aaad7997b2b0918f4d7af4033" - integrity sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg== - dependencies: - postcss "^7.0.0" +postcss-discard-comments@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.0.1.tgz#9eae4b747cf760d31f2447c27f0619d5718901fe" + integrity sha512-lgZBPTDvWrbAYY1v5GYEv8fEO/WhKOu/hmZqmCYfrpD6eyDWWzAOsl2rF29lpvziKO02Gc5GJQtlpkTmakwOWg== -postcss-discard-duplicates@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-4.0.2.tgz#3fe133cd3c82282e550fc9b239176a9207b784eb" - integrity sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ== - dependencies: - postcss "^7.0.0" +postcss-discard-duplicates@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.0.1.tgz#68f7cc6458fe6bab2e46c9f55ae52869f680e66d" + integrity sha512-svx747PWHKOGpAXXQkCc4k/DsWo+6bc5LsVrAsw+OU+Ibi7klFZCyX54gjYzX4TH+f2uzXjRviLARxkMurA2bA== -postcss-discard-empty@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-4.0.1.tgz#c8c951e9f73ed9428019458444a02ad90bb9f765" - integrity sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w== - dependencies: - postcss "^7.0.0" +postcss-discard-empty@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.0.1.tgz#ee136c39e27d5d2ed4da0ee5ed02bc8a9f8bf6d8" + integrity sha512-vfU8CxAQ6YpMxV2SvMcMIyF2LX1ZzWpy0lqHDsOdaKKLQVQGVP1pzhrI9JlsO65s66uQTfkQBKBD/A5gp9STFw== -postcss-discard-overridden@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-4.0.1.tgz#652aef8a96726f029f5e3e00146ee7a4e755ff57" - integrity sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg== - dependencies: - postcss "^7.0.0" +postcss-discard-overridden@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.0.1.tgz#454b41f707300b98109a75005ca4ab0ff2743ac6" + integrity sha512-Y28H7y93L2BpJhrdUR2SR2fnSsT+3TVx1NmVQLbcnZWwIUpJ7mfcTC6Za9M2PG6w8j7UQRfzxqn8jU2VwFxo3Q== postcss-flexbugs-fixes@^4.2.1: version "4.2.1" @@ -22865,67 +23082,57 @@ postcss-loader@^4.2.0: schema-utils "^3.0.0" semver "^7.3.4" -postcss-merge-longhand@^4.0.11: - version "4.0.11" - resolved "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-4.0.11.tgz#62f49a13e4a0ee04e7b98f42bb16062ca2549e24" - integrity sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw== +postcss-merge-longhand@^5.0.4: + version "5.0.4" + resolved "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.0.4.tgz#41f4f3270282ea1a145ece078b7679f0cef21c32" + integrity sha512-2lZrOVD+d81aoYkZDpWu6+3dTAAGkCKbV5DoRhnIR7KOULVrI/R7bcMjhrH9KTRy6iiHKqmtG+n/MMj1WmqHFw== dependencies: - css-color-names "0.0.4" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - stylehacks "^4.0.0" + postcss-value-parser "^4.1.0" + stylehacks "^5.0.1" -postcss-merge-rules@^4.0.3: - version "4.0.3" - resolved "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-4.0.3.tgz#362bea4ff5a1f98e4075a713c6cb25aefef9a650" - integrity sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ== +postcss-merge-rules@^5.0.3: + version "5.0.3" + resolved "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.0.3.tgz#b5cae31f53129812a77e3eb1eeee448f8cf1a1db" + integrity sha512-cEKTMEbWazVa5NXd8deLdCnXl+6cYG7m2am+1HzqH0EnTdy8fRysatkaXb2dEnR+fdaDxTvuZ5zoBdv6efF6hg== dependencies: - browserslist "^4.0.0" + browserslist "^4.16.6" caniuse-api "^3.0.0" - cssnano-util-same-parent "^4.0.0" - postcss "^7.0.0" - postcss-selector-parser "^3.0.0" - vendors "^1.0.0" + cssnano-utils "^2.0.1" + postcss-selector-parser "^6.0.5" -postcss-minify-font-values@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-4.0.2.tgz#cd4c344cce474343fac5d82206ab2cbcb8afd5a6" - integrity sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg== +postcss-minify-font-values@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.0.1.tgz#a90cefbfdaa075bd3dbaa1b33588bb4dc268addf" + integrity sha512-7JS4qIsnqaxk+FXY1E8dHBDmraYFWmuL6cgt0T1SWGRO5bzJf8sUoelwa4P88LEWJZweHevAiDKxHlofuvtIoA== dependencies: - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + postcss-value-parser "^4.1.0" -postcss-minify-gradients@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-4.0.2.tgz#93b29c2ff5099c535eecda56c4aa6e665a663471" - integrity sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q== +postcss-minify-gradients@^5.0.3: + version "5.0.3" + resolved "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.0.3.tgz#f970a11cc71e08e9095e78ec3a6b34b91c19550e" + integrity sha512-Z91Ol22nB6XJW+5oe31+YxRsYooxOdFKcbOqY/V8Fxse1Y3vqlNRpi1cxCqoACZTQEhl+xvt4hsbWiV5R+XI9Q== dependencies: - cssnano-util-get-arguments "^4.0.0" - is-color-stop "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + colord "^2.9.1" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-minify-params@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-4.0.2.tgz#6b9cef030c11e35261f95f618c90036d680db874" - integrity sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg== +postcss-minify-params@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.0.2.tgz#1b644da903473fbbb18fbe07b8e239883684b85c" + integrity sha512-qJAPuBzxO1yhLad7h2Dzk/F7n1vPyfHfCCh5grjGfjhi1ttCnq4ZXGIW77GSrEbh9Hus9Lc/e/+tB4vh3/GpDg== dependencies: - alphanum-sort "^1.0.0" - browserslist "^4.0.0" - cssnano-util-get-arguments "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - uniqs "^2.0.0" + alphanum-sort "^1.0.2" + browserslist "^4.16.6" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-minify-selectors@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-4.0.2.tgz#e2e5eb40bfee500d0cd9243500f5f8ea4262fbd8" - integrity sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g== +postcss-minify-selectors@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.1.0.tgz#4385c845d3979ff160291774523ffa54eafd5a54" + integrity sha512-NzGBXDa7aPsAcijXZeagnJBKBPMYLaJJzB8CQh6ncvyl2sIndLVWfbcDi0SBjRWk5VqEjXvf8tYwzoKf4Z07og== dependencies: - alphanum-sort "^1.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-selector-parser "^3.0.0" + alphanum-sort "^1.0.2" + postcss-selector-parser "^6.0.5" postcss-modules-extract-imports@^2.0.0: version "2.0.0" @@ -23002,124 +23209,96 @@ postcss-modules@^4.0.0: postcss-modules-values "^4.0.0" string-hash "^1.1.1" -postcss-normalize-charset@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-4.0.1.tgz#8b35add3aee83a136b0471e0d59be58a50285dd4" - integrity sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g== - dependencies: - postcss "^7.0.0" +postcss-normalize-charset@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.0.1.tgz#121559d1bebc55ac8d24af37f67bd4da9efd91d0" + integrity sha512-6J40l6LNYnBdPSk+BHZ8SF+HAkS4q2twe5jnocgd+xWpz/mx/5Sa32m3W1AA8uE8XaXN+eg8trIlfu8V9x61eg== -postcss-normalize-display-values@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.2.tgz#0dbe04a4ce9063d4667ed2be476bb830c825935a" - integrity sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ== +postcss-normalize-display-values@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.0.1.tgz#62650b965981a955dffee83363453db82f6ad1fd" + integrity sha512-uupdvWk88kLDXi5HEyI9IaAJTE3/Djbcrqq8YgjvAVuzgVuqIk3SuJWUisT2gaJbZm1H9g5k2w1xXilM3x8DjQ== dependencies: - cssnano-util-get-match "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-normalize-positions@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-4.0.2.tgz#05f757f84f260437378368a91f8932d4b102917f" - integrity sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA== +postcss-normalize-positions@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.0.1.tgz#868f6af1795fdfa86fbbe960dceb47e5f9492fe5" + integrity sha512-rvzWAJai5xej9yWqlCb1OWLd9JjW2Ex2BCPzUJrbaXmtKtgfL8dBMOOMTX6TnvQMtjk3ei1Lswcs78qKO1Skrg== dependencies: - cssnano-util-get-arguments "^4.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + postcss-value-parser "^4.1.0" -postcss-normalize-repeat-style@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-4.0.2.tgz#c4ebbc289f3991a028d44751cbdd11918b17910c" - integrity sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q== +postcss-normalize-repeat-style@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.0.1.tgz#cbc0de1383b57f5bb61ddd6a84653b5e8665b2b5" + integrity sha512-syZ2itq0HTQjj4QtXZOeefomckiV5TaUO6ReIEabCh3wgDs4Mr01pkif0MeVwKyU/LHEkPJnpwFKRxqWA/7O3w== dependencies: - cssnano-util-get-arguments "^4.0.0" - cssnano-util-get-match "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-normalize-string@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-4.0.2.tgz#cd44c40ab07a0c7a36dc5e99aace1eca4ec2690c" - integrity sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA== +postcss-normalize-string@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.0.1.tgz#d9eafaa4df78c7a3b973ae346ef0e47c554985b0" + integrity sha512-Ic8GaQ3jPMVl1OEn2U//2pm93AXUcF3wz+OriskdZ1AOuYV25OdgS7w9Xu2LO5cGyhHCgn8dMXh9bO7vi3i9pA== dependencies: - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + postcss-value-parser "^4.1.0" -postcss-normalize-timing-functions@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-4.0.2.tgz#8e009ca2a3949cdaf8ad23e6b6ab99cb5e7d28d9" - integrity sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A== +postcss-normalize-timing-functions@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.0.1.tgz#8ee41103b9130429c6cbba736932b75c5e2cb08c" + integrity sha512-cPcBdVN5OsWCNEo5hiXfLUnXfTGtSFiBU9SK8k7ii8UD7OLuznzgNRYkLZow11BkQiiqMcgPyh4ZqXEEUrtQ1Q== dependencies: - cssnano-util-get-match "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-normalize-unicode@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-4.0.1.tgz#841bd48fdcf3019ad4baa7493a3d363b52ae1cfb" - integrity sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg== +postcss-normalize-unicode@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.0.1.tgz#82d672d648a411814aa5bf3ae565379ccd9f5e37" + integrity sha512-kAtYD6V3pK0beqrU90gpCQB7g6AOfP/2KIPCVBKJM2EheVsBQmx/Iof+9zR9NFKLAx4Pr9mDhogB27pmn354nA== dependencies: - browserslist "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + browserslist "^4.16.0" + postcss-value-parser "^4.1.0" -postcss-normalize-url@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-4.0.1.tgz#10e437f86bc7c7e58f7b9652ed878daaa95faae1" - integrity sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA== +postcss-normalize-url@^5.0.3: + version "5.0.3" + resolved "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.0.3.tgz#42eca6ede57fe69075fab0f88ac8e48916ef931c" + integrity sha512-qWiUMbvkRx3kc1Dp5opzUwc7MBWZcSDK2yofCmdvFBCpx+zFPkxBC1FASQ59Pt+flYfj/nTZSkmF56+XG5elSg== dependencies: - is-absolute-url "^2.0.0" - normalize-url "^3.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + is-absolute-url "^3.0.3" + normalize-url "^6.0.1" + postcss-value-parser "^4.1.0" -postcss-normalize-whitespace@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-4.0.2.tgz#bf1d4070fe4fcea87d1348e825d8cc0c5faa7d82" - integrity sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA== +postcss-normalize-whitespace@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.0.1.tgz#b0b40b5bcac83585ff07ead2daf2dcfbeeef8e9a" + integrity sha512-iPklmI5SBnRvwceb/XH568yyzK0qRVuAG+a1HFUsFRf11lEJTiQQa03a4RSCQvLKdcpX7XsI1Gen9LuLoqwiqA== dependencies: - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + postcss-value-parser "^4.1.0" -postcss-ordered-values@^4.1.2: - version "4.1.2" - resolved "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-4.1.2.tgz#0cf75c820ec7d5c4d280189559e0b571ebac0eee" - integrity sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw== +postcss-ordered-values@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.0.2.tgz#1f351426977be00e0f765b3164ad753dac8ed044" + integrity sha512-8AFYDSOYWebJYLyJi3fyjl6CqMEG/UVworjiyK1r573I56kb3e879sCJLGvR3merj+fAdPpVplXKQZv+ey6CgQ== dependencies: - cssnano-util-get-arguments "^4.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" -postcss-reduce-initial@^4.0.3: - version "4.0.3" - resolved "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz#7fd42ebea5e9c814609639e2c2e84ae270ba48df" - integrity sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA== +postcss-reduce-initial@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.0.1.tgz#9d6369865b0f6f6f6b165a0ef5dc1a4856c7e946" + integrity sha512-zlCZPKLLTMAqA3ZWH57HlbCjkD55LX9dsRyxlls+wfuRfqCi5mSlZVan0heX5cHr154Dq9AfbH70LyhrSAezJw== dependencies: - browserslist "^4.0.0" + browserslist "^4.16.0" caniuse-api "^3.0.0" - has "^1.0.0" - postcss "^7.0.0" -postcss-reduce-transforms@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.2.tgz#17efa405eacc6e07be3414a5ca2d1074681d4e29" - integrity sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg== +postcss-reduce-transforms@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.0.1.tgz#93c12f6a159474aa711d5269923e2383cedcf640" + integrity sha512-a//FjoPeFkRuAguPscTVmRQUODP+f3ke2HqFNgGPwdYnpeC29RZdCBvGRGTsKpMURb/I3p6jdKoBQ2zI+9Q7kA== dependencies: - cssnano-util-get-match "^4.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - -postcss-selector-parser@^3.0.0: - version "3.1.2" - resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz#b310f5c4c0fdaf76f94902bbaa30db6aa84f5270" - integrity sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA== - dependencies: - dot-prop "^5.2.0" - indexes-of "^1.0.1" - uniq "^1.0.1" + cssnano-utils "^2.0.1" + postcss-value-parser "^4.1.0" postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4: version "6.0.4" @@ -23131,36 +23310,36 @@ postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2, postcss-selector 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" - integrity sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw== +postcss-selector-parser@^6.0.5: + version "6.0.6" + resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz#2c5bba8174ac2f6981ab631a42ab0ee54af332ea" + integrity sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg== dependencies: - is-svg "^3.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" - svgo "^1.0.0" + cssesc "^3.0.0" + util-deprecate "^1.0.2" -postcss-unique-selectors@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-4.0.1.tgz#9446911f3289bfd64c6d680f073c03b1f9ee4bac" - integrity sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg== +postcss-svgo@^5.0.3: + version "5.0.3" + resolved "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.0.3.tgz#d945185756e5dfaae07f9edb0d3cae7ff79f9b30" + integrity sha512-41XZUA1wNDAZrQ3XgWREL/M2zSw8LJPvb5ZWivljBsUQAGoEKMYm6okHsTjJxKYI4M75RQEH4KYlEM52VwdXVA== dependencies: - alphanum-sort "^1.0.0" - postcss "^7.0.0" - uniqs "^2.0.0" + postcss-value-parser "^4.1.0" + svgo "^2.7.0" -postcss-value-parser@^3.0.0: - version "3.3.1" - resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" - integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== +postcss-unique-selectors@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.0.2.tgz#5d6893daf534ae52626708e0d62250890108c0c1" + integrity sha512-w3zBVlrtZm7loQWRPVC0yjUwwpty7OM6DnEHkxcSQXO1bMS3RJ+JUS5LFMSDZHJcvGsRwhZinCWVqn8Kej4EDA== + dependencies: + alphanum-sort "^1.0.2" + postcss-selector-parser "^6.0.5" postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== -"postcss@5 - 7", postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.26, postcss@^7.0.27, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: +"postcss@5 - 7", postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.26, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: version "7.0.32" resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d" integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw== @@ -23311,6 +23490,16 @@ pretty-format@^26.0.0, pretty-format@^26.6.2: ansi-styles "^4.0.0" react-is "^17.0.1" +pretty-format@^27.0.2: + version "27.3.1" + resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-27.3.1.tgz#7e9486365ccdd4a502061fa761d3ab9ca1b78df5" + integrity sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA== + dependencies: + "@jest/types" "^27.2.5" + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + pretty-hrtime@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" @@ -23843,7 +24032,7 @@ react-debounce-input@=3.2.4: lodash.debounce "^4" prop-types "^15.7.2" -react-dev-utils@^11.0.3, react-dev-utils@^11.0.4: +react-dev-utils@^11.0.3: version "11.0.4" resolved "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz#a7ccb60257a1ca2e0efe7a83e38e6700d17aa37a" integrity sha512-dx0LvIGHcOPtKbeiSUM4jqpBl3TcY7CDjZdfOIcKeznE7BWr9dg0iPG90G5yfVQ+p/rGNMXdbfStvzQZEVEi4A== @@ -23873,6 +24062,36 @@ react-dev-utils@^11.0.3, react-dev-utils@^11.0.4: strip-ansi "6.0.0" text-table "0.2.0" +react-dev-utils@^12.0.0-next.47: + version "12.0.0-next.47" + resolved "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.0-next.47.tgz#e55c31a05eb30cfd69ca516e8b87d61724e880fb" + integrity sha512-PsE71vP15TZMmp/RZKOJC4fYD5Pvt0+wCoyG3QHclto0d4FyIJI78xGRICOOThZFROqgXYlZP6ddmeybm+jO4w== + dependencies: + "@babel/code-frame" "^7.10.4" + address "^1.1.2" + browserslist "^4.16.5" + chalk "^2.4.2" + cross-spawn "^7.0.3" + detect-port-alt "^1.1.6" + escape-string-regexp "^2.0.0" + filesize "^6.1.0" + find-up "^4.1.0" + fork-ts-checker-webpack-plugin "^6.0.5" + global-modules "^2.0.0" + globby "^11.0.1" + gzip-size "^5.1.1" + immer "^9.0.6" + is-root "^2.1.0" + loader-utils "^2.0.0" + open "^7.0.2" + pkg-up "^3.1.0" + prompts "^2.4.0" + react-error-overlay "7.0.0-next.54+1465357b" + recursive-readdir "^2.2.2" + shell-quote "^1.7.2" + strip-ansi "^6.0.0" + text-table "^0.2.0" + react-docgen-typescript@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.1.0.tgz#20db64a7fd62e63a8a9469fb4abd90600878cbb2" @@ -23922,6 +24141,11 @@ react-error-boundary@^3.1.0: dependencies: "@babel/runtime" "^7.12.5" +react-error-overlay@7.0.0-next.54+1465357b: + version "7.0.0-next.54" + resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-7.0.0-next.54.tgz#c1eb5ab86aee15e9552e6d97897b08f2bd06d140" + integrity sha512-b96CiTnZahXPDNH9MKplvt5+jD+BkxDw7q5R3jnkUXze/ux1pLv32BBZmlj0OfCUeMqyz4sAmF+0ccJGVMlpXw== + react-error-overlay@^6.0.9: version "6.0.9" resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" @@ -24677,7 +24901,7 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" -regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.0: +regexp.prototype.flags@^1.2.0: version "1.3.0" resolved "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz#7aba89b3c13a64509dabcf3ca8d9fbb9bdf5cb75" integrity sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ== @@ -24685,6 +24909,14 @@ regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.0: define-properties "^1.1.3" es-abstract "^1.17.0-next.1" +regexp.prototype.flags@^1.3.1: + version "1.3.1" + resolved "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz#7ef352ae8d159e758c0eadca6f8fcb4eef07be26" + integrity sha512-JiBdRBq91WlY7uRJ0ds7R+dU02i6LKi8r3BuQhNXn+kmeLN+EfHhfjqMRis1zJxnlu88hq/4dx0P2OP3APRTOA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + regexpp@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2" @@ -25036,11 +25268,6 @@ resolve-from@5.0.0, resolve-from@^5.0.0: resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve-from@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" - integrity sha1-six699nWiBvItuZTM17rywoYh0g= - resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -25059,6 +25286,14 @@ resolve@^1.1.6, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14 is-core-module "^2.2.0" path-parse "^1.0.6" +resolve@^2.0.0-next.3: + version "2.0.0-next.3" + resolved "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46" + integrity sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q== + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + resolve@~1.17.0: version "1.17.0" resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" @@ -25152,16 +25387,6 @@ rfc4648@^1.3.0: resolved "https://registry.npmjs.org/rfc4648/-/rfc4648-1.4.0.tgz#c75b2856ad2e2d588b6ddb985d556f1f7f2a2abd" integrity sha512-3qIzGhHlMHA6PoT6+cdPKZ+ZqtxkIvg8DZGKA5z6PQ33/uuhoJ+Ws/D/J9rXW6gXodgH8QYlz2UCl+sdUDmNIg== -rgb-regex@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" - integrity sha1-wODWiC3w4jviVKR16O3UGRX+rrE= - -rgba-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" - integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= - rifm@^0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/rifm/-/rifm-0.7.0.tgz#debe951a9c83549ca6b33e5919f716044c2230be" @@ -25205,23 +25430,23 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" -rollup-plugin-dts@^3.0.1: - version "3.0.2" - resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-3.0.2.tgz#2b628d88f864d271d6eaec2e4c2a60ae4e944c5c" - integrity sha512-hswlsdWu/x7k5pXzaLP6OvKRKcx8Bzprksz9i9mUe72zvt8LvqAb/AZpzs6FkLgmyRaN8B6rUQOVtzA3yEt9Yw== +rollup-plugin-dts@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-4.0.1.tgz#930cbd5aaaa64a55e895ecd6ae8234e1a5467710" + integrity sha512-DNv5F8pro/r0Hkx3JWKRtJZocDnqXfgypoajeiaNq134rYaFcEIl/oas5PogD1qexMadVijsHyVko1Chig0OOQ== dependencies: magic-string "^0.25.7" optionalDependencies: - "@babel/code-frame" "^7.12.13" + "@babel/code-frame" "^7.14.5" -rollup-plugin-esbuild@2.6.x: - version "2.6.1" - resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.6.1.tgz#5785532940d49adf1bff5b38e9bd9089262d4e7a" - integrity sha512-hskMEQQ4Vxlyoeg1OWlFTwWHIhpNaw6q+diOT7p9pdkk34m9Mbk3aymS/JbTqLXy/AbJi22iuXrucknKpeczfg== +rollup-plugin-esbuild@^4.7.2: + version "4.7.2" + resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-4.7.2.tgz#1a496a9f96257cdf5ed800e818932859232471f8" + integrity sha512-rBS2hTedtG+wL/yyIWQ84zju5rtfF15gkaCLN0vsWGmBdRd0UPm52meAwkmrsPQf3mB/H2o+k9Q8Ce8A66SE5A== dependencies: - "@rollup/pluginutils" "^4.1.0" - joycon "^2.2.5" - strip-json-comments "^3.1.1" + "@rollup/pluginutils" "^4.1.1" + joycon "^3.0.1" + jsonc-parser "^3.0.0" rollup-plugin-peer-deps-external@^2.2.2: version "2.2.4" @@ -25229,13 +25454,13 @@ rollup-plugin-peer-deps-external@^2.2.2: integrity sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g== 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== + version "4.0.2" + resolved "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.2.tgz#15e9462f39475059b368ce0e49c800fa4b1f7050" + integrity sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w== dependencies: chalk "^4.1.0" concat-with-sourcemaps "^1.1.0" - cssnano "^4.1.10" + cssnano "^5.0.1" import-cwd "^3.0.0" p-queue "^6.6.2" pify "^5.0.0" @@ -25254,13 +25479,6 @@ rollup-pluginutils@^2.8.2: dependencies: estree-walker "^0.6.1" -rollup@2.44.x: - version "2.44.0" - resolved "https://registry.npmjs.org/rollup/-/rollup-2.44.0.tgz#8da324d1c4fd12beef9ae6e12f4068265b6d95eb" - integrity sha512-rGSF4pLwvuaH/x4nAS+zP6UNn5YUDWf/TeEU5IoXSZKBbKRNTCI3qMnYXKZgrC0D2KzS2baiOZt1OlqhMu5rnQ== - optionalDependencies: - fsevents "~2.3.1" - rollup@^0.63.4: version "0.63.5" resolved "https://registry.npmjs.org/rollup/-/rollup-0.63.5.tgz#5543eecac9a1b83b7e1be598b5be84c9c0a089db" @@ -25269,6 +25487,13 @@ rollup@^0.63.4: "@types/estree" "0.0.39" "@types/node" "*" +rollup@^2.60.2: + version "2.60.2" + resolved "https://registry.npmjs.org/rollup/-/rollup-2.60.2.tgz#3f45ace36a9b10b4297181831ea0719922513463" + integrity sha512-1Bgjpq61sPjgoZzuiDSGvbI1tD91giZABgjCQBKM5aYLnzjq52GoDuWVwT/cm/MCxCMPU8gqQvkj8doQ5C8Oqw== + optionalDependencies: + fsevents "~2.3.2" + rsvp@^4.8.4: version "4.8.5" resolved "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" @@ -25440,7 +25665,7 @@ schema-utils@^1.0.0: ajv-errors "^1.0.0" ajv-keywords "^3.1.0" -schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0: +schema-utils@^2.6.5, schema-utils@^2.7.0: version "2.7.1" resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg== @@ -25579,10 +25804,12 @@ serialize-error@^8.0.1, serialize-error@^8.1.0: dependencies: type-fest "^0.20.2" -serialize-javascript@^2.1.2: - version "2.1.2" - resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" - integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== +serialize-javascript@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" + integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== + dependencies: + randombytes "^2.1.0" serialize-javascript@^5.0.1: version "5.0.1" @@ -25750,6 +25977,11 @@ shell-quote@1.7.2: resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2" integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== +shell-quote@^1.7.2: + version "1.7.3" + resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123" + integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw== + shelljs@^0.8.3, shelljs@^0.8.4: version "0.8.4" resolved "https://registry.npmjs.org/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2" @@ -25772,7 +26004,7 @@ shx@^0.3.2: minimist "^1.2.3" shelljs "^0.8.4" -side-channel@^1.0.2, side-channel@^1.0.4: +side-channel@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== @@ -26208,31 +26440,26 @@ sqlstring@^2.3.2: resolved "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.2.tgz#cdae7169389a1375b18e885f2e60b3e460809514" integrity sha512-vF4ZbYdKS8OnoJAWBmMxCQDkiEBkGQYU7UZPtL8flbDRSNkhaXvRJ279ZtI6M+zDaQovVU4tuRgzK5fVhvFAhg== -ssh-remote-port-forward@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.3.tgz#074cebed6d54a42ce4659d6aa24987e433ff5fef" - integrity sha512-PJ6qGFmB6n0iMCQIp+qzx8qVUE5cgacopvEh63t3NJjEQHOaza/JT9zywmmVlaol/eGtCmpvBXx2A03ih1Y+xg== +ssh-remote-port-forward@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz#72b0c5df8ec27ca300c75805cc6b266dee07e298" + integrity sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ== dependencies: - "@types/ssh2" "^0.5.45" - ssh2 "^0.8.9" + "@types/ssh2" "^0.5.48" + ssh2 "^1.4.0" -ssh2-streams@~0.4.10: - version "0.4.10" - resolved "https://registry.npmjs.org/ssh2-streams/-/ssh2-streams-0.4.10.tgz#48ef7e8a0e39d8f2921c30521d56dacb31d23a34" - integrity sha512-8pnlMjvnIZJvmTzUIIA5nT4jr2ZWNNVHwyXfMGdRJbug9TpI3kd99ffglgfSWqujVv/0gxwMsDn9j9RVst8yhQ== +ssh2@^1.4.0: + version "1.5.0" + resolved "https://registry.npmjs.org/ssh2/-/ssh2-1.5.0.tgz#4dc559ba98a1cbb420e8d42998dfe35d0eda92bc" + integrity sha512-iUmRkhH9KGeszQwDW7YyyqjsMTf4z+0o48Cp4xOwlY5LjtbIAvyd3fwnsoUZW/hXmTCRA3yt7S/Jb9uVjErVlA== dependencies: - asn1 "~0.2.0" + asn1 "^0.2.4" bcrypt-pbkdf "^1.0.2" - streamsearch "~0.1.2" + optionalDependencies: + cpu-features "0.0.2" + nan "^2.15.0" -ssh2@^0.8.7, ssh2@^0.8.9: - version "0.8.9" - resolved "https://registry.npmjs.org/ssh2/-/ssh2-0.8.9.tgz#54da3a6c4ba3daf0d8477a538a481326091815f3" - integrity sha512-GmoNPxWDMkVpMFa9LVVzQZHF6EW3WKmBwL+4/GeILf2hFmix5Isxm7Amamo8o7bHiU0tC+wXsGcUXOxp8ChPaw== - dependencies: - ssh2-streams "~0.4.10" - -sshpk@^1.7.0: +sshpk@^1.14.1, sshpk@^1.7.0: version "1.16.1" resolved "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== @@ -26424,21 +26651,14 @@ stream-shift@^1.0.0: resolved "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== -stream-to-array@^2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz#bbf6b39f5f43ec30bc71babcb37557acecf34353" - integrity sha1-u/azn19D7DC8cbq8s3VXrOzzQ1M= - dependencies: - any-promise "^1.1.0" - stream-transform@^2.0.1: - version "2.0.2" - resolved "https://registry.npmjs.org/stream-transform/-/stream-transform-2.0.2.tgz#3cb7a14c802eb39bc40caaab0535e584f3a65caf" - integrity sha512-J+D5jWPF/1oX+r9ZaZvEXFbu7znjxSkbNAHJ9L44bt/tCVuOEWZlDqU9qJk7N2xBU1S+K2DPpSKeR/MucmCA1Q== + version "2.1.3" + resolved "https://registry.npmjs.org/stream-transform/-/stream-transform-2.1.3.tgz#a1c3ecd72ddbf500aa8d342b0b9df38f5aa598e3" + integrity sha512-9GHUiM5hMiCi6Y03jD2ARC1ettBXkQBoQAe7nJsPknnI0ow10aXjTnew8QtYQmLjzn974BnmWEAJgCY6ZP1DeQ== dependencies: - mixme "^0.3.1" + mixme "^0.5.1" -streamsearch@0.1.2, streamsearch@~0.1.2: +streamsearch@0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= @@ -26513,17 +26733,19 @@ string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" -"string.prototype.matchall@^4.0.0 || ^3.0.1", string.prototype.matchall@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.2.tgz#48bb510326fb9fdeb6a33ceaa81a6ea04ef7648e" - integrity sha512-N/jp6O5fMf9os0JU3E72Qhf590RSRZU/ungsL/qJUYVTNv7hTG0P/dbPjxINVN9jpscu3nzYwKESU3P3RY5tOg== +"string.prototype.matchall@^4.0.0 || ^3.0.1", string.prototype.matchall@^4.0.6: + version "4.0.6" + resolved "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.6.tgz#5abb5dabc94c7b0ea2380f65ba610b3a544b15fa" + integrity sha512-6WgDX8HmQqvEd7J+G6VtAahhsQIssiZ8zl7zKh1VDMFyL3hRTJP4FTNA3RbIp2TOQ9AYNDcc7e3fH0Qbup+DBg== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.17.0" - has-symbols "^1.0.1" - internal-slot "^1.0.2" - regexp.prototype.flags "^1.3.0" - side-channel "^1.0.2" + es-abstract "^1.19.1" + get-intrinsic "^1.1.1" + has-symbols "^1.0.2" + internal-slot "^1.0.3" + regexp.prototype.flags "^1.3.1" + side-channel "^1.0.4" string.prototype.padend@^3.0.0: version "3.1.0" @@ -26708,14 +26930,6 @@ style-inject@^0.3.0: resolved "https://registry.npmjs.org/style-inject/-/style-inject-0.3.0.tgz#d21c477affec91811cc82355832a700d22bf8dd3" integrity sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw== -style-loader@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/style-loader/-/style-loader-1.2.1.tgz#c5cbbfbf1170d076cfdd86e0109c5bba114baa1a" - integrity sha512-ByHSTQvHLkWE9Ir5+lGbVOXhxX10fbprhLvdg96wedFZb4NDekDPxVKv5Fwmio+QcMlkkNfuK+5W1peQ5CUhZg== - dependencies: - loader-utils "^2.0.0" - schema-utils "^2.6.6" - style-loader@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/style-loader/-/style-loader-1.3.0.tgz#828b4a3b3b7e7aa5847ce7bae9e874512114249e" @@ -26724,6 +26938,11 @@ style-loader@^1.3.0: loader-utils "^2.0.0" schema-utils "^2.7.0" +style-loader@^3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz#057dfa6b3d4d7c7064462830f9113ed417d38575" + integrity sha512-GPcQ+LDJbrcxHORTRes6Jy2sfvK2kS6hpSfI/fXhPt+spVzxF6LJ1dHLN9zIGmVaaP044YKaIatFaufENRiDoQ== + style-to-object@0.3.0, style-to-object@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz#b1b790d205991cc783801967214979ee19a76e46" @@ -26731,14 +26950,13 @@ style-to-object@0.3.0, style-to-object@^0.3.0: dependencies: inline-style-parser "0.1.1" -stylehacks@^4.0.0: - version "4.0.3" - resolved "https://registry.npmjs.org/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" - integrity sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g== +stylehacks@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/stylehacks/-/stylehacks-5.0.1.tgz#323ec554198520986806388c7fdaebc38d2c06fb" + integrity sha512-Es0rVnHIqbWzveU1b24kbw92HsebBepxfcqe5iix7t9j0PQqhs0IxXVXv0pY2Bxa08CgMkzD6OWql7kbGOuEdA== dependencies: - browserslist "^4.0.0" - postcss "^7.0.0" - postcss-selector-parser "^3.0.0" + browserslist "^4.16.0" + postcss-selector-parser "^6.0.4" stylis@^4.0.6: version "4.0.7" @@ -26870,7 +27088,7 @@ svg-parser@^2.0.2: resolved "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== -svgo@^1.0.0, svgo@^1.2.2: +svgo@^1.2.2: version "1.3.2" resolved "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167" integrity sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw== @@ -26889,6 +27107,19 @@ svgo@^1.0.0, svgo@^1.2.2: unquote "~1.1.1" util.promisify "~1.0.0" +svgo@^2.7.0: + version "2.8.0" + resolved "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24" + integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg== + dependencies: + "@trysound/sax" "0.2.0" + commander "^7.2.0" + css-select "^4.1.3" + css-tree "^1.1.3" + csso "^4.2.0" + picocolors "^1.0.0" + stable "^0.1.8" + swagger-client@3.16.1, swagger-client@^3.16.1: version "3.16.1" resolved "https://registry.npmjs.org/swagger-client/-/swagger-client-3.16.1.tgz#df86c9d407ab52c00cb356e714b0ec732bb3ad40" @@ -27172,15 +27403,15 @@ terser-webpack-plugin@*, terser-webpack-plugin@^5.1.3: terser "^5.7.2" terser-webpack-plugin@^1.4.3: - version "1.4.3" - resolved "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz#5ecaf2dbdc5fb99745fd06791f46fc9ddb1c9a7c" - integrity sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA== + version "1.4.5" + resolved "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz#a217aefaea330e734ffacb6120ec1fa312d6040b" + integrity sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw== dependencies: cacache "^12.0.2" find-cache-dir "^2.1.0" is-wsl "^1.1.0" schema-utils "^1.0.0" - serialize-javascript "^2.1.2" + serialize-javascript "^4.0.0" source-map "^0.6.1" terser "^4.1.2" webpack-sources "^1.4.0" @@ -27237,23 +27468,22 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -testcontainers@^7.10.0: - version "7.11.1" - resolved "https://registry.npmjs.org/testcontainers/-/testcontainers-7.11.1.tgz#b3810badf79433ba02f210683eec1a1c488c107d" - integrity sha512-lfZeys5bLkADjOaoXfQy0V0+G8sGKr8ESANz7MhSVBwC+OTTxkP3+FVwP48bW4mwRcQ4Hojwbfw10OYT80QZmQ== +testcontainers@^7.23.0: + version "7.23.0" + resolved "https://registry.npmjs.org/testcontainers/-/testcontainers-7.23.0.tgz#7d73a5e219a970fb75ff6a23a28dace8b7f3f232" + integrity sha512-90H1iijeIjOLp7WVNYKTNkM1sd+dlW5019ns45hSPcOET43WebEZQVJl8/Ag9vwSZD2mjomMum9a/EXk/st4sQ== dependencies: - "@types/archiver" "^5.1.0" - "@types/dockerode" "^3.2.1" + "@types/archiver" "^5.1.1" + "@types/dockerode" "^3.2.5" archiver "^5.3.0" byline "^5.0.0" - debug "^4.3.1" - docker-compose "^0.23.10" - dockerode "^3.2.1" + debug "^4.3.2" + docker-compose "^0.23.13" + dockerode "^3.3.1" get-port "^5.1.1" - glob "^7.1.7" + glob "^7.2.0" slash "^3.0.0" - ssh-remote-port-forward "^1.0.3" - stream-to-array "^2.3.0" + ssh-remote-port-forward "^1.0.4" tar-fs "^2.1.1" text-encoding-utf-8@^1.0.1: @@ -27528,11 +27758,6 @@ tr46@^2.0.2: dependencies: punycode "^2.1.1" -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= - "traverse@>=0.3.0 <0.4": version "0.3.9" resolved "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" @@ -27946,6 +28171,16 @@ unbox-primitive@^1.0.0: has-symbols "^1.0.0" which-boxed-primitive "^1.0.1" +unbox-primitive@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" + integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw== + dependencies: + function-bind "^1.1.1" + has-bigints "^1.0.1" + has-symbols "^1.0.2" + which-boxed-primitive "^1.0.2" + unbzip2-stream@1.3.3: version "1.3.3" resolved "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.3.3.tgz#d156d205e670d8d8c393e1c02ebd506422873f6a" @@ -28047,11 +28282,6 @@ uniq@^1.0.1: resolved "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= -uniqs@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" - integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= - unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" @@ -28591,11 +28821,6 @@ vasync@^2.2.0: dependencies: verror "1.10.0" -vendors@^1.0.0: - version "1.0.4" - resolved "https://registry.npmjs.org/vendors/-/vendors-1.0.4.tgz#e2b800a53e7a29b93506c3cf41100d16c4c4ad8e" - integrity sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w== - verror@1.10.0, verror@^1.8.1: version "1.10.0" resolved "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" @@ -28675,6 +28900,11 @@ vm-browserify@^1.0.1: resolved "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== +vm2@^3.9.5: + version "3.9.5" + resolved "https://registry.npmjs.org/vm2/-/vm2-3.9.5.tgz#5288044860b4bbace443101fcd3bddb2a0aa2496" + integrity sha512-LuCAHZN75H9tdrAiLFf030oW7nJV5xwNMuk1ymOZwopmuK3d2H4L1Kv4+GFHgarKiLfXXLFU+7LDABHnwOkWng== + vscode-languageserver-types@^3.15.1: version "3.15.1" resolved "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.15.1.tgz#17be71d78d2f6236d414f0001ce1ef4d23e6b6de" @@ -28779,11 +29009,6 @@ web-streams-polyfill@4.0.0-beta.1: resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.1.tgz#3b19b9817374b7cee06d374ba7eeb3aeb80e8c95" integrity sha512-3ux37gEX670UUphBF9AMCq8XM6iQ8Ac6A+DSRRjDoRBm1ufCkaCDdNVbaqq60PsEkdNlLKrGtv/YBP4EJXqNtQ== -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= - webidl-conversions@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" @@ -28999,14 +29224,6 @@ whatwg-mimetype@^2.3.0: resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha1-lmRU6HZUYuN2RNNib2dCzotwll0= - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - whatwg-url@^8.0.0, whatwg-url@^8.4.0: version "8.4.0" resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.4.0.tgz#50fb9615b05469591d2b2bd6dfaed2942ed72837" @@ -29016,7 +29233,7 @@ whatwg-url@^8.0.0, whatwg-url@^8.4.0: tr46 "^2.0.2" webidl-conversions "^6.1.0" -which-boxed-primitive@^1.0.1: +which-boxed-primitive@^1.0.1, which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== @@ -29244,9 +29461,9 @@ write-pkg@^4.0.0: write-json-file "^3.2.0" ws@7.4.5, ws@^7.4.6: - version "7.5.5" - resolved "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" - integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w== + version "7.5.6" + resolved "https://registry.npmjs.org/ws/-/ws-7.5.6.tgz#e59fc509fb15ddfb65487ee9765c5a51dec5fe7b" + integrity sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA== ws@7.4.6: version "7.4.6"