From 8f53351a32b28329bb50e940d2d53fd168bfb5c4 Mon Sep 17 00:00:00 2001 From: Esteban Barrios Date: Fri, 12 Feb 2021 16:08:46 +0100 Subject: [PATCH 1/4] Rebase from master (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix client reference * Fix CatalogClient reference in create-app too * Move catalog-client dep to create-app * Add catalog-client dep again.. * Use RequestOptions * tsc * Backwards compatible scaffolder * Fix test * Avoid importing core * Use request options * Increase code coverage * Type those variables * use the second mock * Trying to make tests work on Github too * Get clean yarn from master * Only add authorization header if token exists * Use request options * Forward authorization header token * Refactor to use context argument * Allow the registration of optional locations via the catalog-client * Make EntityRefLink a React.forwardRef in order to use it as root component in other components like ListItem * Rework the user flow of the catalog-import plugin * Update the create-app template * Add luxon and remove moment * Port moment to luxon * Add change set * Split the step label and content to be able to refactor the stepper from vertical to horizontal in the future * Fix tsc, prettier and import issues * Update .changeset/strange-olives-unite.md Co-authored-by: Himanshu Mishra * Update plugins/auth-backend/src/identity/TokenFactory.ts Co-authored-by: Himanshu Mishra * Change from minor to patch * Update DatabaseKeyStore.ts * feat: added a test for running a simple cypress server * Add luxon and remove moment * Port from moment to Luxon * Add changeset * Fixes following CR * Rebased and reinstalled yarn.lock * Changed to fromSQL * Fix prettier formatting * chore: reworking the lock file for the cypress deps * chore: adding cypress.config * chore: reworking some more examples * chore: added some more simple stuff for e2e tests with cypress * chore: reworking again * chore: making cypress nice * chore: added a sample test that works * chore: reworking tests to use he cypress github-action instead * chore: reconfigure Cypress run * chore: don't install - we have deps * chore: dump video recordings too * chore: slimming down install script a little * chore: make pretty * chore: fixing syntax * chore: fixing syntax again * Align card elements with list * Align card elements with List * Add changeset * Removed unused import * chore: archive video footage with the correct path * chore: fix lint warnings * chore: fix prettier again * chore: move files around for ease * Added pattern for component name * Add changeset * stash: push to stash * Return AboutCard title to group name * Refactor children to use shared function * TechDocs: Add visbility to migrate away from basic setup * bugfix: [3310] Favoriting a component resets interface to your owned components update changeset remove unused import fix changeset message fix vale errors? * Create utc DateTime object directly * integration: update the gitlab config mandatory fields to match reality * Resolve review comments * Remove shadowed variables * generateEntityDefinitions should return a PartialEntity * Update roadmap with Backstage Community Sessions * Make sure that SidebarItems are also active when on sub route * Add whitespace around variable * Add changeset * Add word to dictionary * Smater duration display * Add changeset * chore(deps-dev): bump @graphql-codegen/typescript from 1.18.1 to 1.20.2 Bumps [@graphql-codegen/typescript](https://github.com/dotansimha/graphql-code-generator/tree/HEAD/packages/plugins/typescript/typescript) from 1.18.1 to 1.20.2. - [Release notes](https://github.com/dotansimha/graphql-code-generator/releases) - [Changelog](https://github.com/dotansimha/graphql-code-generator/blob/master/packages/plugins/typescript/typescript/CHANGELOG.md) - [Commits](https://github.com/dotansimha/graphql-code-generator/commits/@graphql-codegen/typescript@1.20.2/packages/plugins/typescript/typescript) Signed-off-by: dependabot[bot] * chore: reworking folder structure to move cypress outside of the project * chore: updating workflow to point at the right folder now * chore: fixing cypress build * microsite: Fix color contrast for pre tags * Add package name to lockfile.ts error * Create new-mangos-tap.md * Changed changeset bump to be patch * Pass registered Logger in ServiceBuilderImpl to requestLoggingHandler `requestLoggingHandler` takes an optional `logger` parameter that it can use to log incoming requests. The `ServiceBuilderImpl` was not passing on this logger, if set, to `requestLoggingHandler` middleware * Add indices on columns referring location(id) * Add changeset * Catch catalog errors * Add changeset * Fixed parseUrl to output catalogPaths beginning with '/' * Add configurable OAuth 2.0 scopes - Add oauth2 config for optional scopes - Document oauth2 config keys - Add OAuth2 to demo app list of identity providers * incorrectly added callbackUrl * chore: added a simple readme to run some simple tests against a backstage instance * chore(deps): bump @svgr/plugin-jsx from 5.4.0 to 5.5.0 Bumps [@svgr/plugin-jsx](https://github.com/gregberge/svgr) from 5.4.0 to 5.5.0. - [Release notes](https://github.com/gregberge/svgr/releases) - [Changelog](https://github.com/gregberge/svgr/blob/main/CHANGELOG.md) - [Commits](https://github.com/gregberge/svgr/compare/v5.4.0...v5.5.0) Signed-off-by: dependabot[bot] * chore(deps): bump @types/cors from 2.8.6 to 2.8.9 Bumps [@types/cors](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/cors) from 2.8.6 to 2.8.9. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/cors) Signed-off-by: dependabot[bot] * fix: removing test e2e-test tsconfig.json file * TechDocs: Preparers will take and return an etag for cache invalidation * TechDocs: Implement etag based caching for url preparer Signed-off-by: Himanshu Mishra * TechDocs: Implement etag based caching for git preparer Signed-off-by: Himanshu Mishra * TechDocs: Warn when using legacy git preparer and dir preparer in backstage.io/techdocs-ref Context: https://github.com/backstage/backstage/issues/4409 * TechDocs: Update tests for proper url preparer caching * TechDocs: Deduplicate git clone for cache check Signed-off-by: Himanshu Mishra * TechDocs: Add changesets for deprecation and proper caching * TechDocs: Implement caching for dir preparer * Update the changesets * scaffolder-backend: remove auth-backend dependency * catalog-client: rename ApiContext to CatalogRequestOptions + avoid export * refactor existing usage of ApiContext * techdocs: don't swallow errors other than NotModifiedError * catalog-import: removed bonus code * techdocs: meaningful logs when readTree starts * Flatten the options of the CatalogImportPage * inline optional id token auth headers * Define relationship to software catalog and loose coupling by convention. * Clarify intentions around bulk vs. incremental index management. * Break apart backend plugins & clarify indexer/plugin relationship. * update changeset bump levels * [Search] documentation update (#4459) * delete link to issue as it is closed * replace usage of easy as its very subjective * Update docs/features/search/architecture.md Co-authored-by: Adam Harvey * prettier....: Co-authored-by: Adam Harvey * Own it. * scaffolder: include backstage identity token in requests * review feedback tweaks * docs/apis: update to use named plugin var * sentry: update plugin instance export name * clear other field when toggling reason * changeset * fix(pagerduty): use the luxon date library * Don't pass default as a scope to OIDC providers * docs(TechDocs): Add GitHub Actions CI example with AWS S3 * tech-radar: migrated to new composability API * cost-insights: migrate to new composability API * Changeset * Fix line endings * Prettier * Remove unnecessary scopes from OIDC defaultApi * Bump plugin-auth-backend to a minor change, add documentation for fixing it * Fix md syntax error * Fix Vale spelling error * Remove defaultScopes from OIDC api * [ImgBot] Optimize images /plugins/catalog-import/docs/catalog-import-screenshot.png -- 613.39kb -> 356.48kb (41.88%) Signed-off-by: ImgBotApp * github/workflows: use lax config checks * Use commented-out example value for scope * feat(pagerduty): add changeset * Apply suggestions from code review Co-authored-by: Rémi Doreau <32459935+ayshiff@users.noreply.github.com> * feat(catalog): add entity links card component * update entity links changeset * Fix prettier * chore(deps-dev): bump @types/http-proxy from 1.17.4 to 1.17.5 Bumps [@types/http-proxy](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/http-proxy) from 1.17.4 to 1.17.5. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/http-proxy) Signed-off-by: dependabot[bot] * Revert "Merge pull request #3953 from ayshiff/feature/techdocs-aws-sdkv3" This reverts commit 1c7771871ec46bf55f87fba5991b95938bb57017, reversing changes made to 65d5c8e6fe4364d036d69a0592c87a9037f95b8b. * Revert "Merge pull request #4035 from ayshiff/feature/catalog-backend-aws-sdk-v3" This reverts commit 1df913464889cdf63a1484b06538b76a8baed495, reversing changes made to d45a5100695fb973ae38f74f23bbced9a0101840. * Add changeset * feat: support custom app icons * backend-common: implement UrlReader.search for the other providers too * Remove changeset * docs(TechDocs): Improvements to the CI example Signed-off-by: Himanshu Mishra * fix(catalog): entity links incorrect wrapping * Remove unused functions and luxon * Removed old imports * Fix broken links in documentation (#4418) * Update IdentityApi.md * Update docs/reference/utility-apis/IdentityApi.md * Update docs/reference/utility-apis/IdentityApi.md * ran yarn docgen * Use JS Date instead of Datetime * Add line * Prettier fix * Move parseDate function * Version Packages * chore: fix the create-app version * Limit the props that are forwarded to the Link component in the EntityRefLink * Use routed tabs to link to every settings page * fix up yarn after release * chore: add lockfile to words * catalog-info: add links * Export Select component from core I saw the Select component on storybook and went to use it but it seems it's not exported. Any chance it could be exported? * bug: filepath can be returned as undefined from `git-url-parse` let's default to empty * chore: might as well do this for all parsing * chore: changeset * Add changeset for fixing requestLoggingHandler * chore(deps-dev): bump @storybook/addon-actions from 6.1.11 to 6.1.17 Bumps [@storybook/addon-actions](https://github.com/storybookjs/storybook/tree/HEAD/addons/actions) from 6.1.11 to 6.1.17. - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v6.1.17/addons/actions) Signed-off-by: dependabot[bot] * Port AboutCard * Fix tests * Revert EntityPage changes * docs: Merge auth glossary with main glossary * Remove the "Move repository" menu entry from the catalog page, as it's just a placeholder It will be easy to bring it back later, but for now it just confuses users that it's not doing anything. It's also hard to remove for integrators. * make the template cards conform to mui standard Co-authored-by: Erik Larsson Co-authored-by: Dominik Henneke Co-authored-by: Nils Streijffert Co-authored-by: Nils Streijffert Co-authored-by: Himanshu Mishra Co-authored-by: blam Co-authored-by: Nir Gazit Co-authored-by: Adam Harvey Co-authored-by: NHI TRAN Co-authored-by: Fredrik Adelöw Co-authored-by: Oliver Sand Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Debajyoti Halder Co-authored-by: Patrik Oldsberg Co-authored-by: Gowind Co-authored-by: Alan Crosswell Co-authored-by: Eric Peterson Co-authored-by: Emma Indal Co-authored-by: Ryan Vazquez Co-authored-by: Ryan Vazquez Co-authored-by: Remi Co-authored-by: Ryan Manny Co-authored-by: ImgBotApp Co-authored-by: Rémi Doreau <32459935+ayshiff@users.noreply.github.com> Co-authored-by: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Co-authored-by: Joel Low Co-authored-by: Eric Peterson Co-authored-by: github-actions[bot] Co-authored-by: Iain Billett --- .changeset/afraid-dingos-own.md | 5 + .changeset/afraid-teachers-cross.md | 29 - .changeset/angry-walls-mate.md | 5 + .changeset/chilly-cars-shout.md | 5 + .changeset/cost-insights-tidy-geese-play.md | 5 - .changeset/curly-crabs-compete.md | 5 - .changeset/cyan-lies-flow.md | 5 - .changeset/dry-llamas-wave.md | 7 + .changeset/early-hotels-mate.md | 5 - .changeset/eight-doors-matter.md | 19 + .changeset/empty-cooks-laugh.md | 5 - .changeset/five-guests-promise.md | 5 + .changeset/fluffy-hats-hug.md | 5 - .changeset/four-rings-push.md | 5 - .changeset/funny-students-shout.md | 5 - .changeset/grumpy-cups-hope.md | 5 + .changeset/hot-pants-taste.md | 8 - .changeset/large-terms-invite.md | 7 - .changeset/new-mangos-tap.md | 5 + .changeset/ninety-houses-shout.md | 5 - .changeset/ninety-lemons-shake.md | 5 + .changeset/odd-buckets-compare.md | 5 - .changeset/old-tools-exist.md | 5 - .changeset/perfect-ladybugs-listen.md | 5 - .changeset/poor-sheep-give.md | 5 - .changeset/popular-cars-eat.md | 5 - .changeset/selfish-years-pump.md | 5 - .changeset/shiny-falcons-marry.md | 5 - .changeset/shiny-shirts-worry.md | 5 - .changeset/shy-maps-guess.md | 5 - .changeset/six-elephants-poke.md | 5 + .changeset/sixty-lemons-agree.md | 5 - .changeset/strange-cobras-unite.md | 5 - .changeset/strange-olives-unite.md | 5 + .changeset/stupid-maps-do.md | 5 + .changeset/techdocs-green-rabbits-burn.md | 5 - .changeset/thick-scissors-notice.md | 5 + .changeset/tough-worms-clap.md | 5 + .changeset/tricky-dancers-rush.md | 5 - .changeset/two-dogs-search.md | 5 - .github/CODEOWNERS | 1 + .github/styles/vocab.txt | 2 + .github/workflows/ci.yml | 2 +- .github/workflows/master.yml | 2 +- .github/workflows/tugboat.yml | 28 +- README.md | 1 + app-config.yaml | 7 + catalog-info.yaml | 9 + cypress/.eslintrc.js | 17 + cypress/README.md | 27 + cypress/cypress.json | 8 + cypress/package.json | 11 + cypress/src/fixtures/.gitkeep | 0 cypress/src/integration/catalog.ts | 29 + cypress/src/plugins/index.ts | 16 + cypress/src/support/index.ts | 25 + .../util/urls.ts => cypress/src/types.d.ts | 23 +- cypress/tsconfig.json | 37 + cypress/yarn.lock | 1409 +++++++++++++++++ docs/api/utility-apis.md | 2 +- docs/assets/search/architecture.drawio.svg | 71 +- docs/auth/auth-backend-classes.md | 30 +- docs/features/search/architecture.md | 29 +- docs/features/techdocs/configuring-ci-cd.md | 83 +- docs/features/techdocs/getting-started.md | 14 +- docs/glossary.md | 5 + docs/overview/roadmap.md | 12 +- docs/reference/utility-apis/AlertApi.md | 10 +- docs/reference/utility-apis/AppThemeApi.md | 20 +- .../utility-apis/BackstageIdentityApi.md | 12 +- docs/reference/utility-apis/Config.md | 10 +- docs/reference/utility-apis/DiscoveryApi.md | 4 +- docs/reference/utility-apis/ErrorApi.md | 12 +- .../reference/utility-apis/FeatureFlagsApi.md | 8 +- docs/reference/utility-apis/IdentityApi.md | 4 +- docs/reference/utility-apis/OAuthApi.md | 10 +- .../reference/utility-apis/OAuthRequestApi.md | 16 +- .../utility-apis/OpenIdConnectApi.md | 8 +- docs/reference/utility-apis/ProfileInfoApi.md | 10 +- docs/reference/utility-apis/README.md | 58 +- docs/reference/utility-apis/SessionApi.md | 14 +- docs/reference/utility-apis/StorageApi.md | 12 +- microsite/static/css/custom.css | 4 + packages/app/CHANGELOG.md | 86 + packages/app/package.json | 54 +- packages/app/src/App.tsx | 12 +- .../app/src/components/catalog/EntityPage.tsx | 9 +- packages/app/src/identityProviders.ts | 7 + packages/backend-common/CHANGELOG.md | 11 + packages/backend-common/package.json | 6 +- .../src/reading/AzureUrlReader.test.ts | 117 +- .../src/reading/AzureUrlReader.ts | 71 +- .../src/reading/BitbucketUrlReader.test.ts | 201 ++- .../src/reading/BitbucketUrlReader.ts | 92 +- .../src/reading/GithubUrlReader.test.ts | 76 +- .../src/reading/GithubUrlReader.ts | 30 +- .../src/reading/GitlabUrlReader.test.ts | 137 +- .../src/reading/GitlabUrlReader.ts | 85 +- .../src/reading/tree/TarArchiveResponse.ts | 9 +- .../src/reading/tree/ZipArchiveResponse.ts | 17 +- .../backend-common/src/reading/tree/util.ts | 25 + .../src/service/lib/ServiceBuilderImpl.ts | 2 +- packages/backend/CHANGELOG.md | 29 + packages/backend/package.json | 16 +- packages/catalog-client/CHANGELOG.md | 7 + packages/catalog-client/package.json | 2 +- .../catalog-client/src/CatalogClient.test.ts | 74 +- packages/catalog-client/src/CatalogClient.ts | 76 +- packages/catalog-client/src/index.ts | 8 +- packages/catalog-client/src/types.ts | 31 +- .../components/artist-lookup-component.yaml | 24 +- packages/cli/package.json | 2 +- packages/cli/src/lib/versioning/Lockfile.ts | 2 +- packages/core-api/src/app/App.tsx | 8 +- packages/core-api/src/app/types.ts | 8 +- packages/core-api/src/icons/icons.tsx | 12 +- packages/core-api/src/icons/types.ts | 6 +- packages/core/CHANGELOG.md | 6 + packages/core/package.json | 4 +- packages/core/src/components/index.ts | 1 + packages/core/src/layout/Sidebar/Items.tsx | 1 - packages/create-app/CHANGELOG.md | 96 ++ packages/create-app/package.json | 40 +- .../templates/default-app/app-config.yaml.hbs | 3 + .../default-app/packages/app/src/App.tsx | 14 +- packages/dev-utils/CHANGELOG.md | 11 + packages/dev-utils/package.json | 8 +- packages/e2e-test/.eslintrc.js | 1 - packages/integration/CHANGELOG.md | 32 + packages/integration/package.json | 4 +- packages/integration/src/ScmIntegrations.ts | 9 +- .../src/azure/AzureIntegration.test.ts | 10 + packages/integration/src/azure/index.ts | 3 +- .../src/bitbucket/BitbucketIntegration.ts | 6 +- packages/integration/src/bitbucket/index.ts | 1 + .../src/github/GitHubIntegration.ts | 6 +- packages/integration/src/github/index.ts | 1 + .../src/gitlab/GitLabIntegration.test.ts | 2 + .../src/gitlab/GitLabIntegration.ts | 6 +- .../integration/src/gitlab/config.test.ts | 10 +- packages/integration/src/gitlab/config.ts | 46 +- packages/integration/src/gitlab/core.test.ts | 4 + packages/integration/src/gitlab/index.ts | 1 + packages/integration/src/helpers.test.ts | 68 +- packages/integration/src/helpers.ts | 60 +- packages/integration/src/index.ts | 1 + packages/integration/src/types.ts | 27 +- packages/techdocs-common/CHANGELOG.md | 20 + .../{@aws-sdk/client-s3.ts => aws-sdk.ts} | 71 +- packages/techdocs-common/package.json | 9 +- packages/techdocs-common/src/helpers.test.ts | 5 +- packages/techdocs-common/src/helpers.ts | 38 +- .../src/stages/prepare/commonGit.test.ts | 15 +- .../src/stages/prepare/commonGit.ts | 44 +- .../src/stages/prepare/dir.test.ts | 16 +- .../techdocs-common/src/stages/prepare/dir.ts | 71 +- .../src/stages/prepare/types.ts | 30 +- .../techdocs-common/src/stages/prepare/url.ts | 28 +- .../src/stages/publish/awsS3.test.ts | 21 +- .../src/stages/publish/awsS3.ts | 95 +- .../src/stages/publish/local.ts | 2 +- packages/test-utils/CHANGELOG.md | 6 + packages/test-utils/package.json | 2 +- plugins/api-docs/CHANGELOG.md | 10 + plugins/api-docs/package.json | 10 +- plugins/app-backend/CHANGELOG.md | 11 + plugins/app-backend/package.json | 4 +- plugins/auth-backend/CHANGELOG.md | 72 + plugins/auth-backend/config.d.ts | 8 +- plugins/auth-backend/package.json | 8 +- .../src/identity/DatabaseKeyStore.test.ts | 8 +- .../src/identity/DatabaseKeyStore.ts | 21 +- .../src/identity/MemoryKeyStore.ts | 9 +- .../auth-backend/src/identity/TokenFactory.ts | 21 +- plugins/auth-backend/src/identity/types.ts | 2 +- .../lib/catalog/CatalogIdentityClient.test.ts | 13 +- .../src/lib/catalog/CatalogIdentityClient.ts | 7 +- .../src/providers/google/provider.ts | 19 +- .../src/providers/oauth2/provider.ts | 4 + .../src/providers/oidc/provider.ts | 2 +- plugins/catalog-backend/CHANGELOG.md | 29 + .../20210209121210_locations_fk_index.js | 45 + plugins/catalog-backend/package.json | 10 +- ...sOrganizationCloudAccountProcessor.test.ts | 66 +- .../AwsOrganizationCloudAccountProcessor.ts | 15 +- .../GithubDiscoveryProcessor.test.ts | 222 +++ .../processors/GithubDiscoveryProcessor.ts | 130 ++ .../processors/github/github.test.ts | 45 + .../src/ingestion/processors/github/github.ts | 39 + .../src/ingestion/processors/github/index.ts | 6 +- .../src/ingestion/processors/index.ts | 1 + .../src/service/CatalogBuilder.ts | 2 + plugins/catalog-import/CHANGELOG.md | 40 + plugins/catalog-import/README.md | 85 +- plugins/catalog-import/dev/index.tsx | 335 +++- .../docs/catalog-import-screenshot.png | Bin 0 -> 365037 bytes plugins/catalog-import/package.json | 16 +- .../src/api/CatalogImportApi.ts | 39 +- .../src/api/CatalogImportClient.test.ts | 358 ++++- .../src/api/CatalogImportClient.ts | 202 ++- plugins/catalog-import/src/api/GitHub.ts | 39 + .../src/assets/catalog-import-screenshot.png | Bin 22602 -> 0 bytes .../src/components/Buttons/index.tsx | 70 + .../src/components/ComponentConfigDisplay.tsx | 200 --- .../EntityListComponent.tsx | 165 ++ .../components/EntityListComponent/index.ts | 17 + .../components/ImportComponentForm.test.tsx | 90 -- .../src/components/ImportComponentForm.tsx | 154 -- .../components/ImportComponentPage.test.tsx | 260 +-- .../src/components/ImportComponentPage.tsx | 159 +- .../src/components/ImportFinished.tsx | 58 - .../src/components/ImportStepper.tsx | 42 - .../ImportStepper/ImportStepper.tsx | 104 ++ .../src/components/ImportStepper/defaults.tsx | 293 ++++ .../src/components/ImportStepper/index.ts | 18 + .../catalog-import/src/components/Router.tsx | 8 +- .../StepFinishImportLocation.tsx | 65 + .../StepFinishImportLocation/index.ts | 17 + .../StepInitAnalyzeUrl.test.tsx | 401 +++++ .../StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx | 159 ++ .../components/StepInitAnalyzeUrl/index.ts | 17 + .../AutocompleteTextField.tsx | 100 ++ .../PreparePullRequestForm.test.tsx | 113 ++ .../PreparePullRequestForm.tsx | 67 + .../PreviewCatalogInfoComponent.test.tsx | 101 ++ .../PreviewCatalogInfoComponent.tsx | 56 + .../PreviewPullRequestComponent.test.tsx | 83 + .../PreviewPullRequestComponent.tsx | 40 + .../StepPrepareCreatePullRequest.test.tsx | 287 ++++ .../StepPrepareCreatePullRequest.tsx | 249 +++ .../StepPrepareCreatePullRequest/index.ts | 21 + .../StepPrepareSelectLocations.test.tsx | 202 +++ .../StepPrepareSelectLocations.tsx | 124 ++ .../StepPrepareSelectLocations/index.ts | 17 + .../StepReviewLocation/StepReviewLocation.tsx | 134 ++ .../components/StepReviewLocation/index.ts | 17 + .../catalog-import/src/components/index.ts | 20 + .../src/components/useImportState.test.tsx | 372 +++++ .../src/components/useImportState.ts | 291 ++++ plugins/catalog-import/src/index.ts | 1 + .../mocks/analyze-location-POST-response.json | 22 - .../src/mocks/locations-POST-response.json | 39 - plugins/catalog-import/src/plugin.ts | 27 +- .../catalog-import/src/{util => }/types.ts | 0 .../catalog-import/src/util/useGithubRepos.ts | 121 -- .../catalog-import/src/util/validate.test.ts | 33 - plugins/catalog-react/CHANGELOG.md | 11 + plugins/catalog-react/package.json | 10 +- .../EntityRefLink/EntityRefLink.tsx | 74 +- plugins/catalog/CHANGELOG.md | 35 + plugins/catalog/package.json | 15 +- .../catalog/src/CatalogClientWrapper.test.ts | 169 ++ plugins/catalog/src/CatalogClientWrapper.ts | 97 ++ .../components/AboutCard/AboutCard.test.tsx | 21 +- .../src/components/AboutCard/AboutCard.tsx | 11 +- .../CatalogFilter/CatalogFilter.tsx | 8 + .../CatalogPage/CatalogPage.test.tsx | 22 +- .../components/CatalogPage/CatalogPage.tsx | 18 +- .../components/CatalogPage/utils/timeUtil.js | 26 - .../CatalogPage/utils/timeUtil.test.js | 15 +- .../EntityContextMenu/EntityContextMenu.tsx | 7 - .../EntityLinksCard/EntityLinksCard.test.tsx | 81 + .../EntityLinksCard/EntityLinksCard.tsx | 58 + .../EntityLinksCard/EntityLinksEmptyState.tsx | 65 + .../EntityLinksCard/IconLink.test.tsx | 38 + .../components/EntityLinksCard/IconLink.tsx | 58 + .../EntityLinksCard/LinksGridList.tsx | 47 + .../src/components/EntityLinksCard/index.ts} | 6 +- .../src/components/EntityLinksCard/types.ts | 19 + .../EntityLinksCard/useDynamicColumns.tsx | 49 + .../UnregisterEntityDialog.tsx | 2 +- .../src/filter/useEntityFilterGroup.ts | 10 +- plugins/catalog/src/index.ts | 2 + plugins/catalog/src/plugin.ts | 28 +- plugins/circleci/CHANGELOG.md | 10 + plugins/circleci/package.json | 10 +- plugins/cloudbuild/CHANGELOG.md | 10 + plugins/cloudbuild/package.json | 10 +- plugins/cost-insights/CHANGELOG.md | 10 + plugins/cost-insights/dev/index.tsx | 4 +- plugins/cost-insights/package.json | 8 +- .../src/forms/AlertDismissForm.tsx | 3 + plugins/cost-insights/src/index.ts | 8 +- plugins/cost-insights/src/plugin.test.ts | 4 +- plugins/cost-insights/src/plugin.ts | 59 +- plugins/explore/CHANGELOG.md | 10 + plugins/explore/package.json | 10 +- .../DomainExplorerContent.test.tsx | 13 + .../DomainExplorerContent.tsx | 10 +- plugins/fossa/CHANGELOG.md | 10 + plugins/fossa/package.json | 10 +- plugins/fossa/src/api/FossaClient.test.ts | 25 +- plugins/fossa/src/api/FossaClient.ts | 11 +- plugins/fossa/src/plugin.ts | 10 +- plugins/gcp-projects/package.json | 6 +- plugins/github-actions/CHANGELOG.md | 12 + plugins/github-actions/package.json | 12 +- plugins/gitops-profiles/CHANGELOG.md | 8 + plugins/gitops-profiles/package.json | 8 +- plugins/graphiql/package.json | 6 +- plugins/jenkins/CHANGELOG.md | 11 + plugins/jenkins/package.json | 10 +- .../jenkins/src/components/Cards/Cards.tsx | 14 +- plugins/kafka/CHANGELOG.md | 10 + plugins/kafka/package.json | 10 +- plugins/kubernetes/CHANGELOG.md | 11 + plugins/kubernetes/package.json | 10 +- .../src/api/KubernetesBackendClient.ts | 11 +- plugins/kubernetes/src/plugin.ts | 7 +- plugins/lighthouse/CHANGELOG.md | 10 + plugins/lighthouse/package.json | 10 +- plugins/newrelic/CHANGELOG.md | 8 + plugins/newrelic/package.json | 8 +- plugins/org/CHANGELOG.md | 10 + plugins/org/package.json | 10 +- .../Group/GroupProfile/GroupProfileCard.tsx | 107 +- .../User/UserProfileCard/UserProfileCard.tsx | 74 +- plugins/pagerduty/CHANGELOG.md | 11 + plugins/pagerduty/package.json | 16 +- .../components/Incident/IncidentListItem.tsx | 10 +- plugins/register-component/CHANGELOG.md | 10 + plugins/register-component/package.json | 10 +- plugins/rollbar/CHANGELOG.md | 13 + plugins/rollbar/package.json | 10 +- plugins/rollbar/src/api/RollbarClient.ts | 14 +- plugins/rollbar/src/plugin.ts | 6 +- plugins/scaffolder-backend/CHANGELOG.md | 23 + plugins/scaffolder-backend/package.json | 9 +- .../docs-template/template.yaml | 1 + .../src/lib/catalog/CatalogEntityClient.ts | 40 +- .../scaffolder/stages/prepare/bitbucket.ts | 2 +- .../src/scaffolder/stages/prepare/github.ts | 2 +- .../scaffolder/stages/prepare/gitlab.test.ts | 4 +- .../src/scaffolder/stages/prepare/gitlab.ts | 2 +- .../scaffolder/stages/publish/gitlab.test.ts | 3 + .../scaffolder/stages/publish/publishers.ts | 2 + .../scaffolder-backend/src/service/router.ts | 9 +- plugins/scaffolder/CHANGELOG.md | 13 + plugins/scaffolder/dev/index.tsx | 7 +- plugins/scaffolder/package.json | 12 +- plugins/scaffolder/src/api.ts | 16 +- .../components/TemplateCard/TemplateCard.tsx | 23 +- .../TemplatePage/TemplatePage.test.tsx | 6 +- .../components/TemplatePage/TemplatePage.tsx | 4 +- plugins/scaffolder/src/plugin.ts | 6 +- plugins/search/CHANGELOG.md | 13 + plugins/search/package.json | 10 +- plugins/sentry/CHANGELOG.md | 10 + plugins/sentry/package.json | 10 +- plugins/sentry/src/extensions.tsx | 6 +- plugins/sentry/src/index.ts | 2 +- plugins/sentry/src/plugin.test.ts | 4 +- plugins/sentry/src/plugin.ts | 2 +- plugins/sonarqube/CHANGELOG.md | 9 + plugins/sonarqube/package.json | 10 +- plugins/tech-radar/CHANGELOG.md | 8 + plugins/tech-radar/dev/index.tsx | 11 +- plugins/tech-radar/package.json | 8 +- plugins/tech-radar/src/index.ts | 6 +- plugins/tech-radar/src/plugin.test.ts | 4 +- plugins/tech-radar/src/plugin.ts | 24 +- plugins/techdocs-backend/CHANGELOG.md | 21 + plugins/techdocs-backend/package.json | 6 +- .../DocsBuilder/BuildMetadataStorage.test.ts | 8 +- .../src/DocsBuilder/BuildMetadataStorage.ts | 15 +- .../src/DocsBuilder/builder.ts | 165 +- .../techdocs-backend/src/service/router.ts | 39 +- plugins/techdocs/CHANGELOG.md | 18 + plugins/techdocs/package.json | 14 +- plugins/user-settings/package.json | 6 +- .../src/components/SettingsPage.tsx | 40 +- plugins/welcome/package.json | 6 +- yarn.lock | 1181 ++++---------- 373 files changed, 11341 insertions(+), 3680 deletions(-) create mode 100644 .changeset/afraid-dingos-own.md delete mode 100644 .changeset/afraid-teachers-cross.md create mode 100644 .changeset/angry-walls-mate.md create mode 100644 .changeset/chilly-cars-shout.md delete mode 100644 .changeset/cost-insights-tidy-geese-play.md delete mode 100644 .changeset/curly-crabs-compete.md delete mode 100644 .changeset/cyan-lies-flow.md create mode 100644 .changeset/dry-llamas-wave.md delete mode 100644 .changeset/early-hotels-mate.md create mode 100644 .changeset/eight-doors-matter.md delete mode 100644 .changeset/empty-cooks-laugh.md create mode 100644 .changeset/five-guests-promise.md delete mode 100644 .changeset/fluffy-hats-hug.md delete mode 100644 .changeset/four-rings-push.md delete mode 100644 .changeset/funny-students-shout.md create mode 100644 .changeset/grumpy-cups-hope.md delete mode 100644 .changeset/hot-pants-taste.md delete mode 100644 .changeset/large-terms-invite.md create mode 100644 .changeset/new-mangos-tap.md delete mode 100644 .changeset/ninety-houses-shout.md create mode 100644 .changeset/ninety-lemons-shake.md delete mode 100644 .changeset/odd-buckets-compare.md delete mode 100644 .changeset/old-tools-exist.md delete mode 100644 .changeset/perfect-ladybugs-listen.md delete mode 100644 .changeset/poor-sheep-give.md delete mode 100644 .changeset/popular-cars-eat.md delete mode 100644 .changeset/selfish-years-pump.md delete mode 100644 .changeset/shiny-falcons-marry.md delete mode 100644 .changeset/shiny-shirts-worry.md delete mode 100644 .changeset/shy-maps-guess.md create mode 100644 .changeset/six-elephants-poke.md delete mode 100644 .changeset/sixty-lemons-agree.md delete mode 100644 .changeset/strange-cobras-unite.md create mode 100644 .changeset/strange-olives-unite.md create mode 100644 .changeset/stupid-maps-do.md delete mode 100644 .changeset/techdocs-green-rabbits-burn.md create mode 100644 .changeset/thick-scissors-notice.md create mode 100644 .changeset/tough-worms-clap.md delete mode 100644 .changeset/tricky-dancers-rush.md delete mode 100644 .changeset/two-dogs-search.md create mode 100644 cypress/.eslintrc.js create mode 100644 cypress/README.md create mode 100644 cypress/cypress.json create mode 100644 cypress/package.json create mode 100644 cypress/src/fixtures/.gitkeep create mode 100644 cypress/src/integration/catalog.ts create mode 100644 cypress/src/plugins/index.ts create mode 100644 cypress/src/support/index.ts rename plugins/catalog-import/src/util/urls.ts => cypress/src/types.d.ts (62%) create mode 100644 cypress/tsconfig.json create mode 100644 cypress/yarn.lock create mode 100644 packages/backend-common/src/reading/tree/util.ts rename packages/techdocs-common/__mocks__/{@aws-sdk/client-s3.ts => aws-sdk.ts} (52%) mode change 100755 => 100644 packages/techdocs-common/src/stages/publish/awsS3.test.ts create mode 100644 plugins/catalog-backend/migrations/20210209121210_locations_fk_index.js create mode 100644 plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts create mode 100644 plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts create mode 100644 plugins/catalog-import/docs/catalog-import-screenshot.png create mode 100644 plugins/catalog-import/src/api/GitHub.ts delete mode 100644 plugins/catalog-import/src/assets/catalog-import-screenshot.png create mode 100644 plugins/catalog-import/src/components/Buttons/index.tsx delete mode 100644 plugins/catalog-import/src/components/ComponentConfigDisplay.tsx create mode 100644 plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx create mode 100644 plugins/catalog-import/src/components/EntityListComponent/index.ts delete mode 100644 plugins/catalog-import/src/components/ImportComponentForm.test.tsx delete mode 100644 plugins/catalog-import/src/components/ImportComponentForm.tsx delete mode 100644 plugins/catalog-import/src/components/ImportFinished.tsx delete mode 100644 plugins/catalog-import/src/components/ImportStepper.tsx create mode 100644 plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx create mode 100644 plugins/catalog-import/src/components/ImportStepper/defaults.tsx create mode 100644 plugins/catalog-import/src/components/ImportStepper/index.ts create mode 100644 plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx create mode 100644 plugins/catalog-import/src/components/StepFinishImportLocation/index.ts create mode 100644 plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx create mode 100644 plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx create mode 100644 plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts create mode 100644 plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx create mode 100644 plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx create mode 100644 plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx create mode 100644 plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.test.tsx create mode 100644 plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx create mode 100644 plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx create mode 100644 plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx create mode 100644 plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx create mode 100644 plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx create mode 100644 plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts create mode 100644 plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx create mode 100644 plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.tsx create mode 100644 plugins/catalog-import/src/components/StepPrepareSelectLocations/index.ts create mode 100644 plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx create mode 100644 plugins/catalog-import/src/components/StepReviewLocation/index.ts create mode 100644 plugins/catalog-import/src/components/index.ts create mode 100644 plugins/catalog-import/src/components/useImportState.test.tsx create mode 100644 plugins/catalog-import/src/components/useImportState.ts delete mode 100644 plugins/catalog-import/src/mocks/analyze-location-POST-response.json delete mode 100644 plugins/catalog-import/src/mocks/locations-POST-response.json rename plugins/catalog-import/src/{util => }/types.ts (100%) delete mode 100644 plugins/catalog-import/src/util/useGithubRepos.ts delete mode 100644 plugins/catalog-import/src/util/validate.test.ts create mode 100644 plugins/catalog/src/CatalogClientWrapper.test.ts create mode 100644 plugins/catalog/src/CatalogClientWrapper.ts create mode 100644 plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx create mode 100644 plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx create mode 100644 plugins/catalog/src/components/EntityLinksCard/EntityLinksEmptyState.tsx create mode 100644 plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx create mode 100644 plugins/catalog/src/components/EntityLinksCard/IconLink.tsx create mode 100644 plugins/catalog/src/components/EntityLinksCard/LinksGridList.tsx rename plugins/{catalog-import/src/util/validate.ts => catalog/src/components/EntityLinksCard/index.ts} (76%) create mode 100644 plugins/catalog/src/components/EntityLinksCard/types.ts create mode 100644 plugins/catalog/src/components/EntityLinksCard/useDynamicColumns.tsx diff --git a/.changeset/afraid-dingos-own.md b/.changeset/afraid-dingos-own.md new file mode 100644 index 0000000000..9f7010d330 --- /dev/null +++ b/.changeset/afraid-dingos-own.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +pass registered logger to requestLoggingHandler diff --git a/.changeset/afraid-teachers-cross.md b/.changeset/afraid-teachers-cross.md deleted file mode 100644 index 8fc376441f..0000000000 --- a/.changeset/afraid-teachers-cross.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Pass on plugin database management instance that is now required by the scaffolder plugin. - -To apply this change to an existing application, add the following to `src/plugins/scaffolder.ts`: - -```diff -export default async function createPlugin({ - logger, - config, -+ database, -}: PluginEnvironment) { - -// ...omitted... - - return await createRouter({ - preparers, - templaters, - publishers, - logger, - config, - dockerClient, - entityClient, -+ database, - }); -} -``` diff --git a/.changeset/angry-walls-mate.md b/.changeset/angry-walls-mate.md new file mode 100644 index 0000000000..5902168b2c --- /dev/null +++ b/.changeset/angry-walls-mate.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': minor +--- + +Make `ScmIntegration.resolveUrl` mandatory. diff --git a/.changeset/chilly-cars-shout.md b/.changeset/chilly-cars-shout.md new file mode 100644 index 0000000000..60871b07a3 --- /dev/null +++ b/.changeset/chilly-cars-shout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Make the `TemplateCard` conform to what material-ui recommends in their examples. This fixes the extra padding around the buttons. diff --git a/.changeset/cost-insights-tidy-geese-play.md b/.changeset/cost-insights-tidy-geese-play.md deleted file mode 100644 index f8aa503ca6..0000000000 --- a/.changeset/cost-insights-tidy-geese-play.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cost-insights': patch ---- - -Fix snooze quarter option diff --git a/.changeset/curly-crabs-compete.md b/.changeset/curly-crabs-compete.md deleted file mode 100644 index 552a41a9f1..0000000000 --- a/.changeset/curly-crabs-compete.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-circleci': patch ---- - -Migrated to new composability API, exporting the plugin instance as `circleCIPlugin`, the entity page content as `EntityCircleCIContent`, and entity conditional as `isCircleCIAvailable`. diff --git a/.changeset/cyan-lies-flow.md b/.changeset/cyan-lies-flow.md deleted file mode 100644 index e7da9fab0a..0000000000 --- a/.changeset/cyan-lies-flow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-search': minor ---- - -Migrated to new composability API, exporting the plugin instance as `searchPlugin`, and page as `SearchPage`. Due to the old router component also being called `SearchPage`, this is a breaking change. The old page component is now exported as `Router`, which can be used to maintain the old behavior. diff --git a/.changeset/dry-llamas-wave.md b/.changeset/dry-llamas-wave.md new file mode 100644 index 0000000000..fea9b122a6 --- /dev/null +++ b/.changeset/dry-llamas-wave.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-common': patch +--- + +Implement `UrlReader.search` for the other providers (Azure, Bitbucket, GitLab) as well. + +The `UrlReader` subclasses now are implemented in terms of the respective `Integration` class. diff --git a/.changeset/early-hotels-mate.md b/.changeset/early-hotels-mate.md deleted file mode 100644 index 2cda0344bf..0000000000 --- a/.changeset/early-hotels-mate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -use child logger, if provided, to log single location refresh diff --git a/.changeset/eight-doors-matter.md b/.changeset/eight-doors-matter.md new file mode 100644 index 0000000000..7dbb56b6c3 --- /dev/null +++ b/.changeset/eight-doors-matter.md @@ -0,0 +1,19 @@ +--- +'@backstage/core-api': patch +'@backstage/plugin-catalog': patch +--- + +Minor refactoring of BackstageApp.getSystemIcons to support custom registered +icons. Custom Icons can be added using: + +```tsx +import AlarmIcon from '@material-ui/icons/Alarm'; +import MyPersonIcon from './MyPerson'; + +const app = createApp({ + icons: { + user: MyPersonIcon // override system icon + alert: AlarmIcon, // Custom icon + }, +}); +``` diff --git a/.changeset/empty-cooks-laugh.md b/.changeset/empty-cooks-laugh.md deleted file mode 100644 index 8f509f08cd..0000000000 --- a/.changeset/empty-cooks-laugh.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-app-backend': patch ---- - -Added a new `disableConfigInjection` option, which can be used to disable the configuration injection in environments where it can't be used. diff --git a/.changeset/five-guests-promise.md b/.changeset/five-guests-promise.md new file mode 100644 index 0000000000..1d4d0ae303 --- /dev/null +++ b/.changeset/five-guests-promise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Remove the "Move repository" menu entry from the catalog page, as it's just a placeholder. diff --git a/.changeset/fluffy-hats-hug.md b/.changeset/fluffy-hats-hug.md deleted file mode 100644 index b3d06337fd..0000000000 --- a/.changeset/fluffy-hats-hug.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Don't respond to a request twice if an entity has not been found. diff --git a/.changeset/four-rings-push.md b/.changeset/four-rings-push.md deleted file mode 100644 index 500dd0e812..0000000000 --- a/.changeset/four-rings-push.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cloudbuild': patch ---- - -Migrate to new composability API, exporting the plugin instance as `cloudbuildPlugin`, the entity content as `EntityCloudbuildContent`, the entity conditional as `isCloudbuildAvailable`, and entity cards as `EntityLatestCloudbuildRunCard` and `EntityLatestCloudbuildsForBranchCard`. diff --git a/.changeset/funny-students-shout.md b/.changeset/funny-students-shout.md deleted file mode 100644 index 97b273c687..0000000000 --- a/.changeset/funny-students-shout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-register-component': patch ---- - -Migrated to new composability API, exporting the plugin instance as `registerComponentPlugin`, and page as `RegisterComponentPage`. diff --git a/.changeset/grumpy-cups-hope.md b/.changeset/grumpy-cups-hope.md new file mode 100644 index 0000000000..6445c07efa --- /dev/null +++ b/.changeset/grumpy-cups-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Use routed tabs to link to every settings page. diff --git a/.changeset/hot-pants-taste.md b/.changeset/hot-pants-taste.md deleted file mode 100644 index 5cce26c5bf..0000000000 --- a/.changeset/hot-pants-taste.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@backstage/plugin-api-docs': patch -'@backstage/plugin-catalog': patch -'@backstage/plugin-lighthouse': patch -'@backstage/plugin-techdocs': patch ---- - -Improve display of error messages diff --git a/.changeset/large-terms-invite.md b/.changeset/large-terms-invite.md deleted file mode 100644 index 6653ac8e1e..0000000000 --- a/.changeset/large-terms-invite.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': minor ---- - -Introduced `v2` Scaffolder REST API, which uses an implementation that is database backed, making the scaffolder instances stateless. The `createRouter` function now requires a `PluginDatabaseManager` instance to be passed in, commonly available as `database` in the plugin environment in the backend. - -This API should be considered unstable until used by the scaffolder frontend. diff --git a/.changeset/new-mangos-tap.md b/.changeset/new-mangos-tap.md new file mode 100644 index 0000000000..1b57510417 --- /dev/null +++ b/.changeset/new-mangos-tap.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Tweak error message in lockfile parsing to include more information. diff --git a/.changeset/ninety-houses-shout.md b/.changeset/ninety-houses-shout.md deleted file mode 100644 index 845decc74c..0000000000 --- a/.changeset/ninety-houses-shout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Migrated to new composability API, exporting the plugin instance as `techdocsPlugin`, the top-level page as `TechdocsPage`, and the entity content as `EntityTechdocsContent`. diff --git a/.changeset/ninety-lemons-shake.md b/.changeset/ninety-lemons-shake.md new file mode 100644 index 0000000000..960a4eaffe --- /dev/null +++ b/.changeset/ninety-lemons-shake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Limit the props that are forwarded to the `Link` component in the `EntityRefLink`. diff --git a/.changeset/odd-buckets-compare.md b/.changeset/odd-buckets-compare.md deleted file mode 100644 index faf68fb4a0..0000000000 --- a/.changeset/odd-buckets-compare.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Set explicit content-type in error handler responses. diff --git a/.changeset/old-tools-exist.md b/.changeset/old-tools-exist.md deleted file mode 100644 index a9365cb784..0000000000 --- a/.changeset/old-tools-exist.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Slight refactoring in support of a future search implementation in `UrlReader`. Mostly moving code around. diff --git a/.changeset/perfect-ladybugs-listen.md b/.changeset/perfect-ladybugs-listen.md deleted file mode 100644 index 2f9e65c1d9..0000000000 --- a/.changeset/perfect-ladybugs-listen.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-pagerduty': patch ---- - -Migrated to new composability API, exporting the plugin instance as `pagerDutyPlugin`, entity card as `EntityPagerDutyCard`, and entity conditional as `isPagerDutyAvailable`. diff --git a/.changeset/poor-sheep-give.md b/.changeset/poor-sheep-give.md deleted file mode 100644 index 4c52594b35..0000000000 --- a/.changeset/poor-sheep-give.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/test-utils': patch ---- - -Added `mountedRoutes` option to `wrapInTestApp`, allowing routes to be associated to concrete paths to make `useRouteRef` usable in tested components. diff --git a/.changeset/popular-cars-eat.md b/.changeset/popular-cars-eat.md deleted file mode 100644 index 6d3bcdfab2..0000000000 --- a/.changeset/popular-cars-eat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-jenkins': patch ---- - -Migrate to new composability API, exporting the plugin instance as `jenkinsPlugin`, the entity content as `EntityJenkinsContent`, the entity conditional as `isJenkinsAvailable`, and the entity card as `EntityLatestJenkinsRunCard`. diff --git a/.changeset/selfish-years-pump.md b/.changeset/selfish-years-pump.md deleted file mode 100644 index e5cc5f3bc3..0000000000 --- a/.changeset/selfish-years-pump.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-github-actions': patch ---- - -Migrate to new composability API, exporting the plugin instance as `githubActionsPlugin`, the entity content as `EntityGithubActionsContent`, entity conditional as `isGithubActionsAvailable`, and entity cards as `EntityLatestGithubActionRunCard`, `EntityLatestGithubActionsForBranchCard`, and `EntityRecentGithubActionsRunsCard`. diff --git a/.changeset/shiny-falcons-marry.md b/.changeset/shiny-falcons-marry.md deleted file mode 100644 index 4dda68d693..0000000000 --- a/.changeset/shiny-falcons-marry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-newrelic': patch ---- - -Migrate to new composability API, exporting the plugin instance as `newRelicPlugin`, and the root page as `NewRelicPage`. diff --git a/.changeset/shiny-shirts-worry.md b/.changeset/shiny-shirts-worry.md deleted file mode 100644 index b4797b61b4..0000000000 --- a/.changeset/shiny-shirts-worry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Implement `UrlReader.search` which implements glob matching. diff --git a/.changeset/shy-maps-guess.md b/.changeset/shy-maps-guess.md deleted file mode 100644 index 2a50d3d461..0000000000 --- a/.changeset/shy-maps-guess.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-kubernetes': patch ---- - -Migrate to new composability API, exporting the plugin instance as `kubernetesPlugin` and entity content as `EntityKubernetesContent`. diff --git a/.changeset/six-elephants-poke.md b/.changeset/six-elephants-poke.md new file mode 100644 index 0000000000..23e575b67c --- /dev/null +++ b/.changeset/six-elephants-poke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Migrate about card to new composability API, exporting the entity cards as `EntityAboutCard`. diff --git a/.changeset/sixty-lemons-agree.md b/.changeset/sixty-lemons-agree.md deleted file mode 100644 index 7fc438b433..0000000000 --- a/.changeset/sixty-lemons-agree.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-kafka': patch ---- - -Migrate to new composability API, exporting the plugin instance as `kafkaPlugin`, entity content as `EntityKafkaContent`, and entity conditional as `isKafkaAvailable`. diff --git a/.changeset/strange-cobras-unite.md b/.changeset/strange-cobras-unite.md deleted file mode 100644 index d53a324dab..0000000000 --- a/.changeset/strange-cobras-unite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Refactored route response handling to use more explicit types and throw errors. diff --git a/.changeset/strange-olives-unite.md b/.changeset/strange-olives-unite.md new file mode 100644 index 0000000000..7490f6096a --- /dev/null +++ b/.changeset/strange-olives-unite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Migrated the package from using moment to Luxon. #4278 diff --git a/.changeset/stupid-maps-do.md b/.changeset/stupid-maps-do.md new file mode 100644 index 0000000000..1cbac64f68 --- /dev/null +++ b/.changeset/stupid-maps-do.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Export Select component diff --git a/.changeset/techdocs-green-rabbits-burn.md b/.changeset/techdocs-green-rabbits-burn.md deleted file mode 100644 index 5af68c2000..0000000000 --- a/.changeset/techdocs-green-rabbits-burn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Enhance API calls to support trapping 500 errors from techdocs-backend diff --git a/.changeset/thick-scissors-notice.md b/.changeset/thick-scissors-notice.md new file mode 100644 index 0000000000..ccf78f3abf --- /dev/null +++ b/.changeset/thick-scissors-notice.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Make sure that SidebarItems are also active when on sub route. diff --git a/.changeset/tough-worms-clap.md b/.changeset/tough-worms-clap.md new file mode 100644 index 0000000000..3a18593549 --- /dev/null +++ b/.changeset/tough-worms-clap.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Fix parsing of the path to default to empty string not undefined if git-url-parse throws something we don't expect. Fixes the error `The "path" argument must be of type string.` when preparing. diff --git a/.changeset/tricky-dancers-rush.md b/.changeset/tricky-dancers-rush.md deleted file mode 100644 index 301b23d98e..0000000000 --- a/.changeset/tricky-dancers-rush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Fixed type inference of `createRouteRef`. diff --git a/.changeset/two-dogs-search.md b/.changeset/two-dogs-search.md deleted file mode 100644 index 5f673c0cfb..0000000000 --- a/.changeset/two-dogs-search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-gitops-profiles': patch ---- - -Migrated to new composability API, exporting the plugin instance as `gitopsProfilesPlugin` and pages as `GitopsProfilesClusterListPage`, `GitopsProfilesClusterPage`, and `GitopsProfilesCreatePage`. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9a5ef09554..371fae6e6b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,6 +7,7 @@ * @backstage/maintainers /docs/features/techdocs @backstage/techdocs-core /docs/features/search @backstage/techdocs-core +/docs/assets/search @backstage/techdocs-core /plugins/cost-insights @backstage/silver-lining /plugins/cloudbuild @trivago/ebarrios /plugins/search @backstage/techdocs-core diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 2991a6babd..519216ecd8 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -119,6 +119,7 @@ Kumar learnings lerna Lerna +lockfile Luxon magiclink mailto @@ -249,6 +250,7 @@ transpilation Tuite ui unmanaged +unregister untracked upvote url diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa2feebb3e..08ec67162b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: run: yarn prettier:check - name: validate config - run: yarn backstage-cli config:check + run: yarn backstage-cli config:check --lax - name: lint run: yarn lerna -- run lint --since origin/master diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 425e1172a4..60db9ac16b 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -65,7 +65,7 @@ jobs: COMMIT_SHA_BEFORE: '${{ github.event.before }}' - name: validate config - run: yarn backstage-cli config:check + run: yarn backstage-cli config:check --lax - name: lint run: yarn lerna -- run lint diff --git a/.github/workflows/tugboat.yml b/.github/workflows/tugboat.yml index fdb2e1ddc4..bb5257b7c6 100644 --- a/.github/workflows/tugboat.yml +++ b/.github/workflows/tugboat.yml @@ -48,11 +48,14 @@ jobs: target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" }); - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: '14' + - name: yarn install + run: yarn --cwd cypress install + # This is required because the environment_url param that Tugboat uses # to tell us where the preview is located isn't supported unless you # specify the custom Accept header when getting the deployment_status, @@ -77,9 +80,25 @@ jobs: }); console.log(result); return result.data.environment_url; - - name: echo tugboat preview url - run: | - curl ${{steps.get-status-env.outputs.result}} + + - name: cypress run + uses: cypress-io/github-action@v2 + env: + CYPRESS_baseUrl: ${{steps.get-status-env.outputs.result}} + with: + config-file: ./cypress.json + working-directory: ./cypress + browser: chrome + install: false + headless: true + + - name: update artifact + if: ${{ always() }} + uses: actions/upload-artifact@v1 + with: + name: cypress-videos + path: ./cypress/cypress/videos + - name: set status if: ${{ failure() }} uses: actions/github-script@v3 @@ -94,6 +113,7 @@ jobs: context: 'Backstage Tugboat E2E Tests', target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" }); + - name: set status if: ${{ success() }} uses: actions/github-script@v3 diff --git a/README.md b/README.md index 7c04672998..d01be3f54c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how - [Adopters](ADOPTERS.md) - Companies already using Backstage - [Blog](https://backstage.io/blog/) - Announcements and updates - [Newsletter](https://mailchi.mp/spotify/backstage-community) - Subscribe to our email newsletter +- [Backstage Community Sessions](https://github.com/backstage/community) - Join monthly meetup and explore Backstage community - Give us a star ⭐️ - If you are using Backstage or think it is an interesting project, we would love a star ❤️ ## License diff --git a/app-config.yaml b/app-config.yaml index 5240a050a8..9df6540a2c 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -73,6 +73,9 @@ organization: name: My Company # Reference documentation http://backstage.io/docs/features/techdocs/configuration +# Note: After experimenting with basic setup, use CI/CD to generate docs +# and an external cloud storage when deploying TechDocs for production use-case. +# https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach techdocs: builder: 'local' # Alternatives - 'external' generators: @@ -305,6 +308,10 @@ auth: $env: AUTH_OAUTH2_AUTH_URL tokenUrl: $env: AUTH_OAUTH2_TOKEN_URL + ### + # provide a list of scopes as needed for your OAuth2 Server: + # + # scope: saml-login-selector openid profile email oidc: development: metadataUrl: diff --git a/catalog-info.yaml b/catalog-info.yaml index 7e60af5755..0cab0c558a 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -4,6 +4,15 @@ metadata: name: backstage description: | Backstage is an open-source developer portal that puts the developer experience first. + links: + - title: Website + url: http://backstage.io + - title: Documentation + url: https://backstage.io/docs + - title: Storybook + url: https://backstage.io/storybook + - title: Discord Chat + url: https://discord.com/invite/EBHEGzX annotations: github.com/project-slug: backstage/backstage backstage.io/techdocs-ref: url:https://github.com/backstage/backstage diff --git a/cypress/.eslintrc.js b/cypress/.eslintrc.js new file mode 100644 index 0000000000..d7498649a0 --- /dev/null +++ b/cypress/.eslintrc.js @@ -0,0 +1,17 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], + rules: { + 'no-console': 0, + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: true, + optionalDependencies: false, + peerDependencies: false, + bundledDependencies: false, + }, + ], + 'jest/expect-expect': 'off', + 'no-restricted-syntax': 'off', + }, +}; diff --git a/cypress/README.md b/cypress/README.md new file mode 100644 index 0000000000..2bb310e300 --- /dev/null +++ b/cypress/README.md @@ -0,0 +1,27 @@ +# Cypress Tests for Backstage + +Hey 👋 Welcome to the Cypress tests for Backstage. They're designed to be run against the `packages/app` folder in the main repo, and be some form of smoke tests to make sure that we don't break any core functionality. + +They run part of the PR build, and are triggered from the `.github/workflows/tugboat.yml` file. + +The main app gets built up part of a [Tugboat Build](https://tugboat.qa), which when complete, sends a `deployment event` to the PR triggering the aforementioned workflow. + +### Running Locally + +In order to make typescript happy, this `cypress` package is separate from all the Jest dependencies in the monorepo workspaces setup. + +You can run the e2e tests locally pointing at your local running version like so: + +```sh +cd cypress +yarn install +yarn cypress run +``` + +You can open up the `cypress` console by using `yarn cypress open`. + +You can also run towards any Backstage installation by using the Cypress Environment Variable overrides. + +```sh +CYPRESS_baseUrl="http://demo.backstage.io" yarn cypress run +``` diff --git a/cypress/cypress.json b/cypress/cypress.json new file mode 100644 index 0000000000..ebbbe59901 --- /dev/null +++ b/cypress/cypress.json @@ -0,0 +1,8 @@ +{ + "baseUrl": "http://localhost:7000", + "integrationFolder": "./src/integration", + "supportFile": "./src/support", + "fixturesFolder": "./src/fixures", + "pluginsFile": "./src/plugins", + "defaultCommandTimeout": 10000 +} diff --git a/cypress/package.json b/cypress/package.json new file mode 100644 index 0000000000..8549386f7e --- /dev/null +++ b/cypress/package.json @@ -0,0 +1,11 @@ +{ + "name": "@backstage/cypress-tests", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "private": true, + "dependencies": { + "cypress": "^6.4.0", + "typescript": "^4.1.3" + } +} diff --git a/cypress/src/fixtures/.gitkeep b/cypress/src/fixtures/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cypress/src/integration/catalog.ts b/cypress/src/integration/catalog.ts new file mode 100644 index 0000000000..31d39e7efd --- /dev/null +++ b/cypress/src/integration/catalog.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/// +import 'os'; + +describe('Catalog', () => { + describe('default entities', () => { + it('displays the correct amount of entities from default config', () => { + cy.loginAsGuest(); + + cy.visit('/catalog'); + + cy.contains('Owned (7)').should('be.visible'); + }); + }); +}); diff --git a/cypress/src/plugins/index.ts b/cypress/src/plugins/index.ts new file mode 100644 index 0000000000..b90c276d71 --- /dev/null +++ b/cypress/src/plugins/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export default () => {}; diff --git a/cypress/src/support/index.ts b/cypress/src/support/index.ts new file mode 100644 index 0000000000..e17081831e --- /dev/null +++ b/cypress/src/support/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/// + +Cypress.Commands.add('loginAsGuest', () => { + cy.visit('/', { + onLoad: (win: Window) => + win.localStorage.setItem('@backstage/core:SignInPage:provider', 'guest'), + }); +}); + +export {}; diff --git a/plugins/catalog-import/src/util/urls.ts b/cypress/src/types.d.ts similarity index 62% rename from plugins/catalog-import/src/util/urls.ts rename to cypress/src/types.d.ts index c51dc0a999..361aaba9f3 100644 --- a/plugins/catalog-import/src/util/urls.ts +++ b/cypress/src/types.d.ts @@ -13,19 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import parseGitUrl from 'git-url-parse'; - -export type UrlType = 'file' | 'tree'; - -export function urlType(url: string): UrlType { - const { filepathtype, filepath } = parseGitUrl(url); - - if (filepathtype === 'tree' || filepathtype === 'file') { - return filepathtype; - } else if (filepath?.match(/\.ya?ml$/)) { - return 'file'; +declare module 'zombie'; +declare module 'pgtools'; +declare namespace Cypress { + interface Chainable { + /** + * Login as guest + * @example cy.loginAsGuests + */ + loginAsGuest(): Chainable; } - - return 'tree'; } diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 0000000000..d9b4869ecd --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "allowJs": true, + "declaration": true, + "declarationMap": false, + "esModuleInterop": true, + "experimentalDecorators": false, + "forceConsistentCasingInFileNames": true, + "importHelpers": false, + "incremental": true, + "isolatedModules": true, + "jsx": "react", + "lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2020", "ESNext.Promise"], + "module": "ESNext", + "moduleResolution": "node", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "pretty": true, + "removeComments": false, + "resolveJsonModule": true, + "sourceMap": false, + "skipLibCheck": false, + "strict": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "stripInternal": true, + "target": "ES2019", + "types": ["node", "cypress"] + } +} diff --git a/cypress/yarn.lock b/cypress/yarn.lock new file mode 100644 index 0000000000..3c737f6ebc --- /dev/null +++ b/cypress/yarn.lock @@ -0,0 +1,1409 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@cypress/listr-verbose-renderer@^0.4.1": + version "0.4.1" + resolved "https://registry.npmjs.org/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#a77492f4b11dcc7c446a34b3e28721afd33c642a" + integrity sha1-p3SS9LEdzHxEajSz4ochr9M8ZCo= + dependencies: + chalk "^1.1.3" + cli-cursor "^1.0.2" + date-fns "^1.27.2" + 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== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + 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" + +"@cypress/xvfb@^1.2.4": + version "1.2.4" + resolved "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz#2daf42e8275b39f4aa53c14214e557bd14e7748a" + integrity sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q== + dependencies: + debug "^3.1.0" + lodash.once "^4.1.1" + +"@samverschueren/stream-to-observable@^0.3.0": + version "0.3.1" + resolved "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz#a21117b19ee9be70c379ec1877537ef2e1c63301" + integrity sha512-c/qwwcHyafOQuVQJj0IlBjf5yYgBI7YPJ77k4fOJYesb41jio65eaJODRUmfYKhTOFBrIZ66kgvGPlNbjuoRdQ== + dependencies: + any-observable "^0.3.0" + +"@types/sinonjs__fake-timers@^6.0.1": + version "6.0.2" + resolved "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae" + integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg== + +"@types/sizzle@^2.3.2": + version "2.3.2" + 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" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +any-observable@^0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" + integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== + +arch@^2.1.2: + version "2.2.0" + resolved "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" + integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== + +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== + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + +async@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" + integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= + +aws4@^1.8.0: + version "1.11.0" + resolved "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + +blob-util@2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" + integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ== + +bluebird@^3.7.2: + version "3.7.2" + resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" + integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= + +buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + +cachedir@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" + integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + +chalk@^1.0.0, chalk@^1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^2.4.1: + version "2.4.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.0.0, chalk@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" + integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +check-more-types@^2.24.0: + version "2.24.0" + resolved "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" + integrity sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA= + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +cli-cursor@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" + integrity sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc= + dependencies: + restore-cursor "^1.0.1" + +cli-cursor@^2.0.0, cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= + dependencies: + restore-cursor "^2.0.0" + +cli-table3@~0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.0.tgz#b7b1bc65ca8e7b5cef9124e13dc2b21e2ce4faee" + integrity sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ== + dependencies: + object-assign "^4.1.0" + string-width "^4.2.0" + optionalDependencies: + colors "^1.1.2" + +cli-truncate@^0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574" + integrity sha1-nxXPuwcFAFNpIWxiasfQWrkN1XQ= + dependencies: + slice-ansi "0.0.4" + string-width "^1.0.1" + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +colors@^1.1.2: + version "1.4.0" + resolved "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + +combined-stream@^1.0.6, combined-stream@~1.0.6: + version "1.0.8" + resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" + integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== + +common-tags@^1.8.0: + version "1.8.0" + resolved "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" + integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +concat-stream@^1.6.2: + version "1.6.2" + resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +core-util-is@1.0.2, core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +cross-spawn@^7.0.0: + version "7.0.3" + resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +cypress@^6.4.0: + version "6.4.0" + resolved "https://registry.npmjs.org/cypress/-/cypress-6.4.0.tgz#432c516bf4f1a0f042a6aa1f2c3a4278fa35a8b2" + integrity sha512-SrsPsZ4IBterudkoFYBvkQmXOVxclh1/+ytbzpV8AH/D2FA+s2Qy5ISsaRzOFsbQa4KZWoi3AKwREmF1HucYkg== + dependencies: + "@cypress/listr-verbose-renderer" "^0.4.1" + "@cypress/request" "^2.88.5" + "@cypress/xvfb" "^1.2.4" + "@types/sinonjs__fake-timers" "^6.0.1" + "@types/sizzle" "^2.3.2" + arch "^2.1.2" + blob-util "2.0.2" + bluebird "^3.7.2" + cachedir "^2.3.0" + chalk "^4.1.0" + check-more-types "^2.24.0" + cli-table3 "~0.6.0" + commander "^5.1.0" + common-tags "^1.8.0" + dayjs "^1.9.3" + debug "^4.1.1" + eventemitter2 "^6.4.2" + execa "^4.0.2" + executable "^4.1.1" + extract-zip "^1.7.0" + fs-extra "^9.0.1" + getos "^3.2.1" + is-ci "^2.0.0" + is-installed-globally "^0.3.2" + lazy-ass "^1.6.0" + listr "^0.14.3" + lodash "^4.17.19" + log-symbols "^4.0.0" + minimist "^1.2.5" + moment "^2.29.1" + ospath "^1.2.2" + pretty-bytes "^5.4.1" + ramda "~0.26.1" + request-progress "^3.0.0" + supports-color "^7.2.0" + tmp "~0.2.1" + untildify "^4.0.0" + url "^0.11.0" + yauzl "^2.10.0" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +date-fns@^1.27.2: + version "1.30.1" + resolved "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" + integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== + +dayjs@^1.9.3: + version "1.10.4" + resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" + integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== + +debug@^2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^3.1.0: + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +debug@^4.1.1: + version "4.3.1" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms "2.1.2" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +elegant-spinner@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" + integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4= + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +end-of-stream@^1.1.0: + version "1.4.4" + resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +eventemitter2@^6.4.2: + version "6.4.3" + resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" + integrity sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ== + +execa@^4.0.2: + version "4.1.0" + resolved "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" + integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + human-signals "^1.1.1" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.0" + onetime "^5.1.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" + +executable@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz#41532bff361d3e57af4d763b70582db18f5d133c" + integrity sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg== + dependencies: + pify "^2.2.0" + +exit-hook@^1.0.0: + version "1.1.1" + resolved "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" + integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g= + +extend@~3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +extract-zip@^1.7.0: + version "1.7.0" + resolved "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" + integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA== + dependencies: + concat-stream "^1.6.2" + debug "^2.6.9" + mkdirp "^0.5.4" + yauzl "^2.10.0" + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + +extsprintf@^1.2.0: + version "1.4.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" + integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4= + dependencies: + pend "~1.2.0" + +figures@^1.7.0: + version "1.7.0" + resolved "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" + integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4= + dependencies: + escape-string-regexp "^1.0.5" + object-assign "^4.1.0" + +figures@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" + integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= + dependencies: + escape-string-regexp "^1.0.5" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +fs-extra@^9.0.1: + version "9.1.0" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +get-stream@^5.0.0: + version "5.2.0" + resolved "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +getos@^3.2.1: + version "3.2.1" + resolved "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz#0134d1f4e00eb46144c5a9c0ac4dc087cbb27dc5" + integrity sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q== + dependencies: + async "^3.2.0" + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +glob@^7.1.3: + version "7.1.6" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + 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.1.0" + resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-2.1.0.tgz#e9046a49c806ff04d6c1825e196c8f0091e8df4d" + integrity sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ== + dependencies: + ini "1.3.7" + +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.5" + 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" + integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= + dependencies: + ansi-regex "^2.0.0" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-flag@^4.0.0: + version "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= + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +human-signals@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" + integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== + +indent-string@^3.0.0: + version "3.2.0" + resolved "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" + integrity sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok= + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.3, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@1.3.7: + version "1.3.7" + resolved "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" + integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-installed-globally@^0.3.2: + version "0.3.2" + resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" + integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== + dependencies: + global-dirs "^2.0.1" + is-path-inside "^3.0.1" + +is-observable@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/is-observable/-/is-observable-1.1.0.tgz#b3e986c8f44de950867cab5403f5a3465005975e" + integrity sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA== + dependencies: + symbol-observable "^1.1.0" + +is-path-inside@^3.0.1: + version "3.0.2" + resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017" + integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg== + +is-promise@^2.1.0: + version "2.2.2" + resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== + +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" + integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +jsbn@~0.1.0: + version "0.1.1" + 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-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + 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= + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +lazy-ass@^1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513" + integrity sha1-eZllXoZGwX8In90YfRUNMyTVRRM= + +listr-silent-renderer@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" + integrity sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4= + +listr-update-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.npmjs.org/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz#4ea8368548a7b8aecb7e06d8c95cb45ae2ede6a2" + integrity sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA== + dependencies: + chalk "^1.1.3" + cli-truncate "^0.2.1" + elegant-spinner "^1.0.1" + figures "^1.7.0" + indent-string "^3.0.0" + log-symbols "^1.0.2" + log-update "^2.3.0" + strip-ansi "^3.0.1" + +listr-verbose-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.npmjs.org/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz#f1132167535ea4c1261102b9f28dac7cba1e03db" + integrity sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw== + dependencies: + chalk "^2.4.1" + cli-cursor "^2.1.0" + date-fns "^1.27.2" + figures "^2.0.0" + +listr@^0.14.3: + version "0.14.3" + resolved "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz#2fea909604e434be464c50bddba0d496928fa586" + integrity sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA== + dependencies: + "@samverschueren/stream-to-observable" "^0.3.0" + is-observable "^1.1.0" + is-promise "^2.1.0" + is-stream "^1.1.0" + listr-silent-renderer "^1.1.1" + listr-update-renderer "^0.5.0" + listr-verbose-renderer "^0.5.0" + p-map "^2.0.0" + rxjs "^6.3.3" + +lodash.once@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= + +lodash@^4.17.19: + version "4.17.20" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + +log-symbols@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18" + integrity sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg= + dependencies: + chalk "^1.0.0" + +log-symbols@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920" + integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA== + dependencies: + chalk "^4.0.0" + +log-update@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/log-update/-/log-update-2.3.0.tgz#88328fd7d1ce7938b29283746f0b1bc126b24708" + integrity sha1-iDKP19HOeTiykoN0bwsbwSayRwg= + dependencies: + ansi-escapes "^3.0.0" + cli-cursor "^2.0.0" + wrap-ansi "^3.0.1" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +mime-db@1.45.0: + version "1.45.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea" + integrity sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w== + +mime-types@^2.1.12, mime-types@~2.1.19: + version "2.1.28" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz#1160c4757eab2c5363888e005273ecf79d2a0ecd" + integrity sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ== + dependencies: + mime-db "1.45.0" + +mimic-fn@^1.0.0: + version "1.2.0" + resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" + integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +mkdirp@^0.5.4: + version "0.5.5" + resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== + dependencies: + minimist "^1.2.5" + +moment@^2.29.1: + version "2.29.1" + resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +npm-run-path@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +number-is-nan@^1.0.0: + version "1.0.1" + 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" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +onetime@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" + integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k= + +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= + dependencies: + mimic-fn "^1.0.0" + +onetime@^5.1.0: + version "5.1.2" + resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +ospath@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b" + integrity sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs= + +p-map@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +pend@~1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" + integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + +pify@^2.2.0: + version "2.3.0" + resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= + +pretty-bytes@^5.4.1: + version "5.5.0" + resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.5.0.tgz#0cecda50a74a941589498011cf23275aa82b339e" + integrity sha512-p+T744ZyjjiaFlMUZZv6YPC5JrkNj8maRmPaQCWFJFplUAzpIUTRaTcS+7wmZtUoFXHtESJb23ISliaWyz3SHA== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +psl@^1.1.28: + version "1.8.0" + resolved "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" + integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@1.3.2: + version "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: + version "2.1.1" + resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= + +ramda@~0.26.1: + version "0.26.1" + resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" + integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== + +readable-stream@^2.2.2: + version "2.3.7" + resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +request-progress@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe" + integrity sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4= + dependencies: + throttleit "^1.0.0" + +restore-cursor@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" + integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE= + dependencies: + exit-hook "^1.0.0" + onetime "^1.0.0" + +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + +rimraf@^3.0.0: + version "3.0.2" + resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +rxjs@^6.3.3: + version "6.6.3" + resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" + integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== + dependencies: + tslib "^1.9.0" + +safe-buffer@^5.0.1, safe-buffer@^5.1.2: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^3.0.2: + version "3.0.3" + resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" + integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== + +slice-ansi@0.0.4: + version "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: + version "1.16.1" + resolved "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" + integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +string-width@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +string-width@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string-width@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" + integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.0.0" + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0, supports-color@^7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +symbol-observable@^1.1.0: + version "1.2.0" + resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + +throttleit@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" + integrity sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw= + +tmp@~0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" + integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== + dependencies: + rimraf "^3.0.0" + +tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== + dependencies: + psl "^1.1.28" + punycode "^2.1.1" + +tslib@^1.9.0: + version "1.14.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + +typescript@^4.1.3: + version "4.1.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" + integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + +untildify@^4.0.0: + version "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" + integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +util-deprecate@~1.0.1: + version "1.0.2" + 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== + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wrap-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-3.0.1.tgz#288a04d87eda5c286e060dfe8f135ce8d007f8ba" + integrity sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo= + dependencies: + string-width "^2.1.1" + strip-ansi "^4.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +yauzl@^2.10.0: + version "2.10.0" + resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" + integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk= + dependencies: + buffer-crc32 "~0.2.3" + fd-slicer "~1.1.0" diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index ff322c136f..5b5656f1b1 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -119,7 +119,7 @@ Plugins supply their APIs through the `apis` option of `createPlugin`, for example: ```ts -export const plugin = createPlugin({ +export const techdocsPlugin = createPlugin({ id: 'techdocs', apis: [ createApiFactory({ diff --git a/docs/assets/search/architecture.drawio.svg b/docs/assets/search/architecture.drawio.svg index af04ab63e8..a624a375e1 100644 --- a/docs/assets/search/architecture.drawio.svg +++ b/docs/assets/search/architecture.drawio.svg @@ -1,13 +1,13 @@ - + - + - + @@ -66,24 +66,26 @@ - + -
-
+
+
- @backstage/plugin-search-backend + @backstage/ +
+ plugin-search-backend
- - @backstage/plugin-search-backend + + @backstage/... - + @@ -138,9 +140,9 @@ - - - + + + @@ -266,7 +268,7 @@ -
+
Pass Search @@ -280,7 +282,7 @@
- + Pass Search... @@ -352,8 +354,8 @@
- - Gather Documents + + Gather Documents From Plugins
@@ -366,7 +368,7 @@ - + @@ -375,16 +377,14 @@
- Collate Documents -
- Or Metadata + Register Document / Metadata Collation Handler(s)
- Collate Docum... + Register Docu... @@ -427,11 +427,11 @@
- + -
+
@@ -441,7 +441,7 @@
- + Index Processi... @@ -529,10 +529,29 @@ + + + + +
+
+
+ @backstage/ +
+ plugin-search-indexer +
+
+
+
+ + @backstage/... + +
+
- + Viewer does not support full SVG 1.1 diff --git a/docs/auth/auth-backend-classes.md b/docs/auth/auth-backend-classes.md index 424869196d..a99ca119f0 100644 --- a/docs/auth/auth-backend-classes.md +++ b/docs/auth/auth-backend-classes.md @@ -76,14 +76,16 @@ by also providing the `cert` configuration. ### Configuration -Each authentication provider (except SAML) needs five parameters: an OAuth -client ID, a client secret, an authorization endpoint, a token endpoint, and an -app origin. The app origin is the URL at which the frontend of the application -is hosted, and it is read from the `app.baseUrl` config. This is required -because the application opens a popup window to perform the authentication, and -once the flow is completed, the popup window sends a `postMessage` to the -frontend application to indicate the result of the operation. Also this URL is -used to verify that authentication requests are coming from only this endpoint. +Each authentication provider (except SAML) needs six parameters: an OAuth client +ID, a client secret, an authorization endpoint, a token endpoint, an optional +list of scopes (as a string separated by spaces) that may be required by the +OAuth2 Server to enable end-user sign-on, and an app origin. The app origin is +the URL at which the frontend of the application is hosted, and it is read from +the `app.baseUrl` config. This is required because the application opens a popup +window to perform the authentication, and once the flow is completed, the popup +window sends a `postMessage` to the frontend application to indicate the result +of the operation. Also this URL is used to verify that authentication requests +are coming from only this endpoint. These values are configured via the `app-config.yaml` present in the root of your app folder. @@ -109,6 +111,18 @@ auth: development: clientId: $env: + oauth2: + development: + clientId: + $env: AUTH_OAUTH2_CLIENT_ID + clientSecret: + $env: AUTH_OAUTH2_CLIENT_SECRET + authorizationUrl: + $env: AUTH_OAUTH2_AUTH_URL + tokenUrl: + $env: AUTH_OAUTH2_TOKEN_URL + scope: + $env: AUTH_OAUTH2_SCOPE saml: entryPoint: $env: AUTH_SAML_ENTRY_POINT diff --git a/docs/features/search/architecture.md b/docs/features/search/architecture.md index 3075719b9d..118afa6710 100644 --- a/docs/features/search/architecture.md +++ b/docs/features/search/architecture.md @@ -6,9 +6,9 @@ description: Documentation on Search Architecture # Search Architecture -> _This is a proposed architecture which has not been implemented yet. We are -> still looking for feedback to improve the architecture to fit your use-case, -> see [this open issue](https://github.com/backstage/backstage/issues/4078)._ +> _This is a proposed architecture which has not been implemented yet. Find our +> milestones to follow our progress on the +> [Search Roadmap](./README.md#project-roadmap)._ Below you can explore the Search Architecture. Our aim with this architecture is to support a wide variety of search engines, while providing a simple developer @@ -20,7 +20,10 @@ Backstage end-users. At a base-level, we want to support the following: - We aim to enable the capability to search across the entire Backstage - ecosystem by decoupling search from content management. + ecosystem including, but not limited to, entities in the software catalog. + Searchable content won't be required to relate directly to the software + catalog, but by convention, we may encourage loose relationships using + well-known field names or attributes. - We aim to enable the capability to deploy Backstage using any search engine, by providing an integration and translation layer between the core search plugin and search engine specific logic that can be extended for different @@ -29,11 +32,17 @@ At a base-level, we want to support the following: More advanced use-cases we hope to support with this architecture include: -- It should be easy for any plugin to expose new content to search. (e.g. entity - metadata, documentation from TechDocs) -- It should be easy for any plugin to append relevant metadata to existing +- It should be possible for any plugin to expose new content to search. (e.g. + entity metadata, documentation from TechDocs) +- It should be possible for any plugin to append relevant metadata to existing content in search. (e.g. location (path) for TechDocs page) -- It should be easy to refine search queries (e.g. ranking, scoring, etc.) -- It should be easy to customize the search UI -- It should be easy to add search functionality to any Backstage plugin or +- It should be possible to refine search queries (e.g. ranking, scoring, etc.) +- It should be possible to customize the search UI +- It should be possible to add search functionality to any Backstage plugin or deployment + +Architecture non-goals: + +- At this time, we do not intend to directly support event-driven or incremental + index management. Instead, we'll be focused on scheduled, bulk index + management. diff --git a/docs/features/techdocs/configuring-ci-cd.md b/docs/features/techdocs/configuring-ci-cd.md index 5841a02fa8..a9844ef528 100644 --- a/docs/features/techdocs/configuring-ci-cd.md +++ b/docs/features/techdocs/configuring-ci-cd.md @@ -27,11 +27,15 @@ REPOSITORY_URL='https://github.com/org/repo' git clone $REPOSITORY_URL cd repo +# Install @techdocs/cli, mkdocs and mkdocs plugins +npm install -g @techdocs/cli +pip install mkdocs-techdocs-core==0.* + # Generate -npx @techdocs/cli generate +techdocs-cli generate --no-docker # Publish -npx @techdocs/cli publish --publisher-type awsS3 --storage-name --entity +techdocs-cli publish --publisher-type awsS3 --storage-name --entity ``` That's it! @@ -40,14 +44,16 @@ Take a look at [`techdocs-cli` README](https://github.com/backstage/techdocs-cli) for the complete command reference, details, and options. -## 1. Setup a workflow +## Steps + +### 1. Setup a workflow The TechDocs workflow should trigger on CI when any changes are made in the repository containing the documentation files. You can be specific and configure the workflow to be triggered only when files inside the `docs/` directory or `mkdocs.yml` are changed. -## 2. Prepare step +### 2. Prepare step The first step on the CI is to clone your documentation source repository in a working directory. This is almost always the first step in most CI workflows. @@ -62,7 +68,7 @@ step. Eventually we are trying to do a `git clone `. -## 3. Generate step +### 3. Generate step Install [`npx`](https://www.npmjs.com/package/npx) to use it for running `techdocs-cli`. Or you can install using `npm install -g @techdocs/cli`. @@ -78,7 +84,7 @@ npx @techdocs/cli generate --no-docker --source-dir PATH_TO_REPO --output-dir ./ `PATH_TO_REPO` should be the location in the file path where the prepare step above clones the repository. -## 4. Publish step +### 4. Publish step Depending on your cloud storage provider (AWS, Google Cloud, or Azure), set the necessary authentication environment variables. @@ -96,3 +102,68 @@ npx @techdocs/cli publish --publisher-type --storage-name Defined at -[packages/core-api/src/apis/definitions/AlertApi.ts:19](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AlertApi.ts#L19). +[packages/core-api/src/apis/definitions/AlertApi.ts:19](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/AlertApi.ts#L19). Referenced by: [post](#post), [alert\$](#alert). @@ -67,7 +67,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [alert\$](#alert). @@ -86,7 +86,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -109,6 +109,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/AppThemeApi.md b/docs/reference/utility-apis/AppThemeApi.md index 0f5c095825..a662b4cb70 100644 --- a/docs/reference/utility-apis/AppThemeApi.md +++ b/docs/reference/utility-apis/AppThemeApi.md @@ -1,7 +1,7 @@ # AppThemeApi The AppThemeApi type is defined at -[packages/core-api/src/apis/definitions/AppThemeApi.ts:56](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L56). +[packages/core-api/src/apis/definitions/AppThemeApi.ts:56](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/AppThemeApi.ts#L56). The following Utility API implements this type: [appThemeApiRef](./README.md#apptheme) @@ -81,7 +81,7 @@ export type AppTheme = { Defined at -[packages/core-api/src/apis/definitions/AppThemeApi.ts:25](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L25). +[packages/core-api/src/apis/definitions/AppThemeApi.ts:25](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/AppThemeApi.ts#L25). Referenced by: [getInstalledThemes](#getinstalledthemes). @@ -92,7 +92,7 @@ export type BackstagePalette = Palette & Palette Defined at -[packages/theme/src/types.ts:74](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L74). +[packages/theme/src/types.ts:74](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/theme/src/types.ts#L74). Referenced by: [BackstageTheme](#backstagetheme). @@ -107,7 +107,7 @@ export interface BackstageTheme extends Theme { Defined at -[packages/theme/src/types.ts:81](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L81). +[packages/theme/src/types.ts:81](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/theme/src/types.ts#L81). Referenced by: [AppTheme](#apptheme). @@ -136,7 +136,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [activeThemeId\$](#activethemeid). @@ -155,7 +155,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -170,7 +170,7 @@ export type PageTheme = { Defined at -[packages/theme/src/types.ts:103](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L103). +[packages/theme/src/types.ts:103](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/theme/src/types.ts#L103). Referenced by: [BackstageTheme](#backstagetheme). @@ -183,7 +183,7 @@ export type PageThemeSelector = { Defined at -[packages/theme/src/types.ts:77](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L77). +[packages/theme/src/types.ts:77](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/theme/src/types.ts#L77). Referenced by: [BackstageTheme](#backstagetheme). @@ -243,7 +243,7 @@ type PaletteAdditions = { Defined at -[packages/theme/src/types.ts:23](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L23). +[packages/theme/src/types.ts:23](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/theme/src/types.ts#L23). Referenced by: [BackstagePalette](#backstagepalette). @@ -266,6 +266,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/BackstageIdentityApi.md b/docs/reference/utility-apis/BackstageIdentityApi.md index 69fe5d26ba..80a40d427e 100644 --- a/docs/reference/utility-apis/BackstageIdentityApi.md +++ b/docs/reference/utility-apis/BackstageIdentityApi.md @@ -1,7 +1,7 @@ # BackstageIdentityApi The BackstageIdentityApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:134](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L134). +[packages/core-api/src/apis/definitions/auth.ts:134](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L134). The following Utility APIs implement this type: @@ -17,8 +17,12 @@ The following Utility APIs implement this type: - [oauth2ApiRef](./README.md#oauth2) +- [oidcAuthApiRef](./README.md#oidcauth) + - [oktaAuthApiRef](./README.md#oktaauth) +- [oneloginAuthApiRef](./README.md#oneloginauth) + - [samlAuthApiRef](./README.md#samlauth) ## Members @@ -70,7 +74,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getBackstageIdentity](#getbackstageidentity). @@ -79,7 +83,7 @@ Referenced by: [getBackstageIdentity](#getbackstageidentity).
 export type BackstageIdentity = {
   /**
-   * The Backstage user ID.
+   * The backstage user ID.
    */
   id: string;
 
@@ -91,6 +95,6 @@ export type BackstageIdentity = {
 
Defined at -[packages/core-api/src/apis/definitions/auth.ts:147](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L147). +[packages/core-api/src/apis/definitions/auth.ts:147](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L147). Referenced by: [getBackstageIdentity](#getbackstageidentity). diff --git a/docs/reference/utility-apis/Config.md b/docs/reference/utility-apis/Config.md index 2ae047a700..c5b20610e7 100644 --- a/docs/reference/utility-apis/Config.md +++ b/docs/reference/utility-apis/Config.md @@ -1,7 +1,7 @@ # Config The Config type is defined at -[packages/config/src/types.ts:32](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L32). +[packages/config/src/types.ts:32](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/config/src/types.ts#L32). The following Utility API implements this type: [configApiRef](./README.md#config) @@ -140,7 +140,7 @@ export type Config = { Defined at -[packages/config/src/types.ts:32](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L32). +[packages/config/src/types.ts:32](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/config/src/types.ts#L32). Referenced by: [getConfig](#getconfig), [getOptionalConfig](#getoptionalconfig), [getConfigArray](#getconfigarray), @@ -153,7 +153,7 @@ export type JsonArray =
JsonValue[] Defined at -[packages/config/src/types.ts:18](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L18). +[packages/config/src/types.ts:18](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/config/src/types.ts#L18). Referenced by: [JsonValue](#jsonvalue). @@ -164,7 +164,7 @@ export type JsonObject = { [key in string]?: JsonValue Defined at -[packages/config/src/types.ts:17](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L17). +[packages/config/src/types.ts:17](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/config/src/types.ts#L17). Referenced by: [JsonValue](#jsonvalue). @@ -181,7 +181,7 @@ export type JsonValue = Defined at -[packages/config/src/types.ts:19](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L19). +[packages/config/src/types.ts:19](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/config/src/types.ts#L19). Referenced by: [get](#get), [getOptional](#getoptional), [JsonObject](#jsonobject), [JsonArray](#jsonarray), [Config](#config). diff --git a/docs/reference/utility-apis/DiscoveryApi.md b/docs/reference/utility-apis/DiscoveryApi.md index ac278fdd97..0d888c1daa 100644 --- a/docs/reference/utility-apis/DiscoveryApi.md +++ b/docs/reference/utility-apis/DiscoveryApi.md @@ -1,7 +1,7 @@ # DiscoveryApi The DiscoveryApi type is defined at -[packages/core-api/src/apis/definitions/DiscoveryApi.ts:30](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L30). +[packages/core-api/src/apis/definitions/DiscoveryApi.ts:30](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L30). The following Utility API implements this type: [discoveryApiRef](./README.md#discovery) @@ -12,7 +12,7 @@ The following Utility API implements this type: Returns the HTTP base backend URL for a given plugin, without a trailing slash. -This method must always be called just before making a request. as opposed to +This method must always be called just before making a request, as opposed to fetching the URL when constructing an API client. That is to ensure that more flexible routing patterns can be supported. diff --git a/docs/reference/utility-apis/ErrorApi.md b/docs/reference/utility-apis/ErrorApi.md index 9bba0c76c6..1aaecdb47e 100644 --- a/docs/reference/utility-apis/ErrorApi.md +++ b/docs/reference/utility-apis/ErrorApi.md @@ -1,7 +1,7 @@ # ErrorApi The ErrorApi type is defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L53). +[packages/core-api/src/apis/definitions/ErrorApi.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/ErrorApi.ts#L53). The following Utility API implements this type: [errorApiRef](./README.md#error) @@ -41,7 +41,7 @@ type Error = { Defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L24). +[packages/core-api/src/apis/definitions/ErrorApi.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/ErrorApi.ts#L24). Referenced by: [post](#post), [error\$](#error). @@ -58,7 +58,7 @@ export type ErrorContext = { Defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L33). +[packages/core-api/src/apis/definitions/ErrorApi.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/ErrorApi.ts#L33). Referenced by: [post](#post), [error\$](#error). @@ -87,7 +87,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [error\$](#error). @@ -106,7 +106,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -129,6 +129,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/FeatureFlagsApi.md b/docs/reference/utility-apis/FeatureFlagsApi.md index 529d2ac5dc..1e8979c02e 100644 --- a/docs/reference/utility-apis/FeatureFlagsApi.md +++ b/docs/reference/utility-apis/FeatureFlagsApi.md @@ -1,7 +1,7 @@ # FeatureFlagsApi The FeatureFlagsApi type is defined at -[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:60](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L60). +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:60](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L60). The following Utility API implements this type: [featureFlagsApiRef](./README.md#featureflags) @@ -68,7 +68,7 @@ export type FeatureFlag = { Defined at -[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:31](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L31). +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:31](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L31). Referenced by: [registerFlag](#registerflag), [getRegisteredFlags](#getregisteredflags). @@ -83,7 +83,7 @@ export enum FeatureFlagState { Defined at -[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:36](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L36). +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:36](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L36). Referenced by: [FeatureFlagsSaveOptions](#featureflagssaveoptions). @@ -108,6 +108,6 @@ export type FeatureFlagsSaveOptions = { Defined at -[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:44](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L44). +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:44](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L44). Referenced by: [save](#save). diff --git a/docs/reference/utility-apis/IdentityApi.md b/docs/reference/utility-apis/IdentityApi.md index a88aaf0b8a..aa2a4dd9a0 100644 --- a/docs/reference/utility-apis/IdentityApi.md +++ b/docs/reference/utility-apis/IdentityApi.md @@ -1,7 +1,7 @@ # IdentityApi The IdentityApi type is defined at -[packages/core-api/src/apis/definitions/IdentityApi.ts:22](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/IdentityApi.ts#L22). +[packages/core-api/src/apis/definitions/IdentityApi.ts:22](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/IdentityApi.ts#L22). The following Utility API implements this type: [identityApiRef](./README.md#identity) @@ -76,6 +76,6 @@ export type ProfileInfo = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L162). +[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L162). Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/OAuthApi.md b/docs/reference/utility-apis/OAuthApi.md index 79b55812ef..9af6b3bb53 100644 --- a/docs/reference/utility-apis/OAuthApi.md +++ b/docs/reference/utility-apis/OAuthApi.md @@ -1,7 +1,7 @@ # OAuthApi The OAuthApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:67](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L67). +[packages/core-api/src/apis/definitions/auth.ts:67](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L67). The following Utility APIs implement this type: @@ -15,8 +15,12 @@ The following Utility APIs implement this type: - [oauth2ApiRef](./README.md#oauth2) +- [oidcAuthApiRef](./README.md#oidcauth) + - [oktaAuthApiRef](./README.md#oktaauth) +- [oneloginAuthApiRef](./README.md#oneloginauth) + ## Members ### getAccessToken() @@ -82,7 +86,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getAccessToken](#getaccesstoken). @@ -108,6 +112,6 @@ export type OAuthScope = string | string[] Defined at -[packages/core-api/src/apis/definitions/auth.ts:38](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L38). +[packages/core-api/src/apis/definitions/auth.ts:38](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L38). Referenced by: [getAccessToken](#getaccesstoken). diff --git a/docs/reference/utility-apis/OAuthRequestApi.md b/docs/reference/utility-apis/OAuthRequestApi.md index f1c2311ce9..5f521cf288 100644 --- a/docs/reference/utility-apis/OAuthRequestApi.md +++ b/docs/reference/utility-apis/OAuthRequestApi.md @@ -1,7 +1,7 @@ # OAuthRequestApi The OAuthRequestApi type is defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:99](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L99). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:99](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L99). The following Utility API implements this type: [oauthRequestApiRef](./README.md#oauthrequest) @@ -73,7 +73,7 @@ export type AuthProvider = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:27](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L27). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:27](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L27). Referenced by: [AuthRequesterOptions](#authrequesteroptions), [PendingAuthRequest](#pendingauthrequest). @@ -97,7 +97,7 @@ export type AuthRequester<AuthResponse> = ( Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:66](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L66). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:66](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L66). Referenced by: [createAuthRequester](#createauthrequester). @@ -122,7 +122,7 @@ export type AuthRequesterOptions<AuthResponse> = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:43](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L43). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:43](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L43). Referenced by: [createAuthRequester](#createauthrequester). @@ -151,7 +151,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [authRequest\$](#authrequest). @@ -170,7 +170,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -205,7 +205,7 @@ export type PendingAuthRequest = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:77](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L77). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:77](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L77). Referenced by: [authRequest\$](#authrequest). @@ -228,6 +228,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/OpenIdConnectApi.md b/docs/reference/utility-apis/OpenIdConnectApi.md index eaea530708..6d05af6189 100644 --- a/docs/reference/utility-apis/OpenIdConnectApi.md +++ b/docs/reference/utility-apis/OpenIdConnectApi.md @@ -1,7 +1,7 @@ # OpenIdConnectApi The OpenIdConnectApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:99](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L99). +[packages/core-api/src/apis/definitions/auth.ts:99](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L99). The following Utility APIs implement this type: @@ -13,8 +13,12 @@ The following Utility APIs implement this type: - [oauth2ApiRef](./README.md#oauth2) +- [oidcAuthApiRef](./README.md#oidcauth) + - [oktaAuthApiRef](./README.md#oktaauth) +- [oneloginAuthApiRef](./README.md#oneloginauth) + ## Members ### getIdToken() @@ -66,6 +70,6 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getIdToken](#getidtoken). diff --git a/docs/reference/utility-apis/ProfileInfoApi.md b/docs/reference/utility-apis/ProfileInfoApi.md index 76d04045e8..1a2f94d031 100644 --- a/docs/reference/utility-apis/ProfileInfoApi.md +++ b/docs/reference/utility-apis/ProfileInfoApi.md @@ -1,7 +1,7 @@ # ProfileInfoApi The ProfileInfoApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:117](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L117). +[packages/core-api/src/apis/definitions/auth.ts:117](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L117). The following Utility APIs implement this type: @@ -17,8 +17,12 @@ The following Utility APIs implement this type: - [oauth2ApiRef](./README.md#oauth2) +- [oidcAuthApiRef](./README.md#oidcauth) + - [oktaAuthApiRef](./README.md#oktaauth) +- [oneloginAuthApiRef](./README.md#oneloginauth) + - [samlAuthApiRef](./README.md#samlauth) ## Members @@ -67,7 +71,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getProfile](#getprofile). @@ -95,6 +99,6 @@ export type ProfileInfo = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L162). +[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L162). Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/README.md b/docs/reference/utility-apis/README.md index 1ef5b6197e..aefbb4b925 100644 --- a/docs/reference/utility-apis/README.md +++ b/docs/reference/utility-apis/README.md @@ -12,7 +12,7 @@ Used to report alerts and forward them to the app Implemented type: [AlertApi](./AlertApi.md) ApiRef: -[alertApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AlertApi.ts#L41) +[alertApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/AlertApi.ts#L41) ### appTheme @@ -21,7 +21,7 @@ API Used to configure the app theme, and enumerate options Implemented type: [AppThemeApi](./AppThemeApi.md) ApiRef: -[appThemeApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L80) +[appThemeApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/AppThemeApi.ts#L80) ### auth0Auth @@ -32,7 +32,7 @@ Implemented types: [OpenIdConnectApi](./OpenIdConnectApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[auth0AuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L275) +[auth0AuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L275) ### config @@ -41,7 +41,7 @@ Used to access runtime configuration Implemented type: [Config](./Config.md) ApiRef: -[configApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ConfigApi.ts#L22) +[configApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/ConfigApi.ts#L25) ### discovery @@ -50,7 +50,7 @@ Provides service discovery of backend plugins Implemented type: [DiscoveryApi](./DiscoveryApi.md) ApiRef: -[discoveryApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L44) +[discoveryApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L44) ### error @@ -59,7 +59,7 @@ Used to report errors and forward them to the app Implemented type: [ErrorApi](./ErrorApi.md) ApiRef: -[errorApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L65) +[errorApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/ErrorApi.ts#L65) ### featureFlags @@ -68,7 +68,7 @@ Used to toggle functionality in features across Backstage Implemented type: [FeatureFlagsApi](./FeatureFlagsApi.md) ApiRef: -[featureFlagsApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L83) +[featureFlagsApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L83) ### githubAuth @@ -79,7 +79,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[githubAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L232) +[githubAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L232) ### gitlabAuth @@ -90,7 +90,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[gitlabAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L262) +[gitlabAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L262) ### googleAuth @@ -102,7 +102,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[googleAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L215) +[googleAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L215) ### identity @@ -111,7 +111,7 @@ Provides access to the identity of the signed in user Implemented type: [IdentityApi](./IdentityApi.md) ApiRef: -[identityApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/IdentityApi.ts#L54) +[identityApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/IdentityApi.ts#L53) ### microsoftAuth @@ -123,7 +123,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[microsoftAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L289) +[microsoftAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L289) ### oauth2 @@ -135,7 +135,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[oauth2ApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L303) +[oauth2ApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L303) ### oauthRequest @@ -144,7 +144,19 @@ An API for implementing unified OAuth flows in Backstage Implemented type: [OAuthRequestApi](./OAuthRequestApi.md) ApiRef: -[oauthRequestApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130) +[oauthRequestApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130) + +### oidcAuth + +Example of how to use oidc custom provider + +Implemented types: [OAuthApi](./OAuthApi.md), +[OpenIdConnectApi](./OpenIdConnectApi.md), +[ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) + +ApiRef: +[oidcAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L317) ### oktaAuth @@ -156,7 +168,19 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[oktaAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L245) +[oktaAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L245) + +### oneloginAuth + +Provides authentication towards OneLogin APIs and identities + +Implemented types: [OAuthApi](./OAuthApi.md), +[OpenIdConnectApi](./OpenIdConnectApi.md), +[ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) + +ApiRef: +[oneloginAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L338) ### samlAuth @@ -166,7 +190,7 @@ Implemented types: [ProfileInfoApi](./ProfileInfoApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[samlAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L317) +[samlAuthApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L331) ### storage @@ -175,4 +199,4 @@ Provides the ability to store data which is unique to the user Implemented type: [StorageApi](./StorageApi.md) ApiRef: -[storageApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L68) +[storageApiRef](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/StorageApi.ts#L68) diff --git a/docs/reference/utility-apis/SessionApi.md b/docs/reference/utility-apis/SessionApi.md index 7d271558d6..4b584d92c8 100644 --- a/docs/reference/utility-apis/SessionApi.md +++ b/docs/reference/utility-apis/SessionApi.md @@ -1,7 +1,7 @@ # SessionApi The SessionApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:190](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L190). +[packages/core-api/src/apis/definitions/auth.ts:190](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L190). The following Utility APIs implement this type: @@ -17,8 +17,12 @@ The following Utility APIs implement this type: - [oauth2ApiRef](./README.md#oauth2) +- [oidcAuthApiRef](./README.md#oidcauth) + - [oktaAuthApiRef](./README.md#oktaauth) +- [oneloginAuthApiRef](./README.md#oneloginauth) + - [samlAuthApiRef](./README.md#samlauth) ## Members @@ -77,7 +81,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [sessionState\$](#sessionstate). @@ -96,7 +100,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -112,7 +116,7 @@ export enum SessionState { Defined at -[packages/core-api/src/apis/definitions/auth.ts:182](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L182). +[packages/core-api/src/apis/definitions/auth.ts:182](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/auth.ts#L182). Referenced by: [sessionState\$](#sessionstate). @@ -135,6 +139,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/StorageApi.md b/docs/reference/utility-apis/StorageApi.md index 6c5595d3d8..3247d28f60 100644 --- a/docs/reference/utility-apis/StorageApi.md +++ b/docs/reference/utility-apis/StorageApi.md @@ -1,7 +1,7 @@ # StorageApi The StorageApi type is defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L31). +[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/StorageApi.ts#L31). The following Utility API implements this type: [storageApiRef](./README.md#storage) @@ -79,7 +79,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L53). Referenced by: [observe\$](#observe), [StorageApi](#storageapi). @@ -98,7 +98,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -144,7 +144,7 @@ export interface StorageApi { Defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L31). +[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/StorageApi.ts#L31). Referenced by: [forBucket](#forbucket). @@ -158,7 +158,7 @@ export type StorageValueChange<T = any> = { Defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:21](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L21). +[packages/core-api/src/apis/definitions/StorageApi.ts:21](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/apis/definitions/StorageApi.ts#L21). Referenced by: [observe\$](#observe), [StorageApi](#storageapi). @@ -181,6 +181,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/a4dbd8353cfa4d4d4334473e2c33afcda64e130d/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css index c947c88e66..c54e0101d3 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -1115,6 +1115,10 @@ code { font-size: 115%; } +.post pre { + color: #e4e4e4; +} + .Block__GIF { width: 190px; height: auto; diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index fcd9b795a4..6190dda9da 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,91 @@ # example-app +## 0.2.15 + +### Patch Changes + +- 07bafa248: Add configurable `scope` for oauth2 auth provider. + + Some OAuth2 providers require certain scopes to facilitate a user sign-in using the Authorization Code flow. + This change adds the optional `scope` key to auth.providers.oauth2. An example is: + + ```yaml + auth: + providers: + oauth2: + development: + clientId: + $env: DEV_OAUTH2_CLIENT_ID + clientSecret: + $env: DEV_OAUTH2_CLIENT_SECRET + authorizationUrl: + $env: DEV_OAUTH2_AUTH_URL + tokenUrl: + $env: DEV_OAUTH2_TOKEN_URL + scope: saml-login-selector openid profile email + ``` + + This tells the OAuth 2.0 AS to perform a SAML login and return OIDC information include the `profile` + and `email` claims as part of the ID Token. + +- Updated dependencies [753bb4c40] +- Updated dependencies [6ed2b47d6] +- Updated dependencies [b33fa4cf4] +- Updated dependencies [d36660721] +- Updated dependencies [6b26c9f41] +- Updated dependencies [b3f0c3811] +- Updated dependencies [302795d10] +- Updated dependencies [9ec66c345] +- Updated dependencies [53d3e2d62] +- Updated dependencies [ca559171b] +- Updated dependencies [53348f0af] +- Updated dependencies [f5e564cd6] +- Updated dependencies [68dd79d83] +- Updated dependencies [29a138636] +- Updated dependencies [14aef4b94] +- Updated dependencies [41af18227] +- Updated dependencies [1df75733e] +- Updated dependencies [02d6803e8] +- Updated dependencies [b288a291e] +- Updated dependencies [025c0c7bf] +- Updated dependencies [e5da858d7] +- Updated dependencies [9230d07e7] +- Updated dependencies [f5f45744e] +- Updated dependencies [0fe8ff5be] +- Updated dependencies [c5ab91ce3] +- Updated dependencies [64b9efac2] +- Updated dependencies [19d354c78] +- Updated dependencies [7716d1d70] +- Updated dependencies [8f3443427] +- Updated dependencies [b51ee6ece] +- Updated dependencies [accdfeb30] +- Updated dependencies [804502a5c] + - @backstage/plugin-catalog-import@0.4.0 + - @backstage/plugin-catalog@0.3.1 + - @backstage/plugin-kubernetes@0.3.9 + - @backstage/plugin-rollbar@0.3.0 + - @backstage/plugin-scaffolder@0.5.0 + - @backstage/plugin-cost-insights@0.8.1 + - @backstage/plugin-circleci@0.2.8 + - @backstage/plugin-search@0.3.0 + - @backstage/plugin-cloudbuild@0.2.9 + - @backstage/plugin-register-component@0.2.9 + - @backstage/plugin-sentry@0.3.5 + - @backstage/plugin-jenkins@0.3.9 + - @backstage/plugin-api-docs@0.4.5 + - @backstage/plugin-lighthouse@0.2.10 + - @backstage/plugin-techdocs@0.5.6 + - @backstage/plugin-pagerduty@0.2.8 + - @backstage/plugin-org@0.3.6 + - @backstage/plugin-github-actions@0.3.2 + - @backstage/plugin-explore@0.2.5 + - @backstage/plugin-newrelic@0.2.5 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/plugin-kafka@0.2.2 + - @backstage/core@0.6.1 + - @backstage/plugin-gitops-profiles@0.2.5 + - @backstage/plugin-tech-radar@0.3.5 + ## 0.2.14 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 859372b1f8..88d039e7c8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,38 +1,38 @@ { "name": "example-app", - "version": "0.2.14", + "version": "0.2.15", "private": true, "bundled": true, "dependencies": { "@backstage/catalog-model": "^0.7.1", "@backstage/cli": "^0.6.0", - "@backstage/core": "^0.6.0", - "@backstage/plugin-api-docs": "^0.4.4", - "@backstage/plugin-catalog": "^0.3.0", - "@backstage/plugin-catalog-react": "^0.0.2", - "@backstage/plugin-catalog-import": "^0.3.7", - "@backstage/plugin-circleci": "^0.2.7", - "@backstage/plugin-cloudbuild": "^0.2.8", - "@backstage/plugin-cost-insights": "^0.8.0", - "@backstage/plugin-explore": "^0.2.4", + "@backstage/core": "^0.6.1", + "@backstage/plugin-api-docs": "^0.4.5", + "@backstage/plugin-catalog": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.0.3", + "@backstage/plugin-catalog-import": "^0.4.0", + "@backstage/plugin-circleci": "^0.2.8", + "@backstage/plugin-cloudbuild": "^0.2.9", + "@backstage/plugin-cost-insights": "^0.8.1", + "@backstage/plugin-explore": "^0.2.5", "@backstage/plugin-gcp-projects": "^0.2.4", - "@backstage/plugin-github-actions": "^0.3.1", - "@backstage/plugin-gitops-profiles": "^0.2.4", + "@backstage/plugin-github-actions": "^0.3.2", + "@backstage/plugin-gitops-profiles": "^0.2.5", "@backstage/plugin-graphiql": "^0.2.7", - "@backstage/plugin-org": "^0.3.5", - "@backstage/plugin-jenkins": "^0.3.8", - "@backstage/plugin-kafka": "^0.2.1", - "@backstage/plugin-kubernetes": "^0.3.8", - "@backstage/plugin-lighthouse": "^0.2.9", - "@backstage/plugin-newrelic": "^0.2.4", - "@backstage/plugin-pagerduty": "0.2.7", - "@backstage/plugin-register-component": "^0.2.8", - "@backstage/plugin-rollbar": "^0.2.9", - "@backstage/plugin-scaffolder": "^0.4.2", - "@backstage/plugin-sentry": "^0.3.4", - "@backstage/plugin-search": "^0.2.7", - "@backstage/plugin-tech-radar": "^0.3.4", - "@backstage/plugin-techdocs": "^0.5.5", + "@backstage/plugin-org": "^0.3.6", + "@backstage/plugin-jenkins": "^0.3.9", + "@backstage/plugin-kafka": "^0.2.2", + "@backstage/plugin-kubernetes": "^0.3.9", + "@backstage/plugin-lighthouse": "^0.2.10", + "@backstage/plugin-newrelic": "^0.2.5", + "@backstage/plugin-pagerduty": "0.2.8", + "@backstage/plugin-register-component": "^0.2.9", + "@backstage/plugin-rollbar": "^0.3.0", + "@backstage/plugin-scaffolder": "^0.5.0", + "@backstage/plugin-sentry": "^0.3.5", + "@backstage/plugin-search": "^0.3.0", + "@backstage/plugin-tech-radar": "^0.3.5", + "@backstage/plugin-techdocs": "^0.5.6", "@backstage/plugin-user-settings": "^0.2.5", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", @@ -53,7 +53,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/test-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.7", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 00696736d1..cc673c7769 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -23,7 +23,7 @@ import { SignInPage, } from '@backstage/core'; import { Router as CatalogRouter } from '@backstage/plugin-catalog'; -import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import'; +import { CatalogImportPage } from '@backstage/plugin-catalog-import'; import { ExplorePage } from '@backstage/plugin-explore'; import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql'; import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; @@ -39,10 +39,15 @@ import { EntityPage } from './components/catalog/EntityPage'; import Root from './components/Root'; import { providers } from './identityProviders'; import * as plugins from './plugins'; +import AlarmIcon from '@material-ui/icons/Alarm'; const app = createApp({ apis, plugins: Object.values(plugins), + icons: { + // Custom icon example + alert: AlarmIcon, + }, components: { SignInPage: props => { return ( @@ -69,10 +74,7 @@ const catalogRouteRef = createRouteRef({ const routes = ( - } - /> + } /> } diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 5474afa72c..e849667c19 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -28,7 +28,11 @@ import { ProvidedApisCard, ProvidingComponentsCard, } from '@backstage/plugin-api-docs'; -import { AboutCard, EntityPageLayout } from '@backstage/plugin-catalog'; +import { + AboutCard, + EntityLinksCard, + EntityPageLayout, +} from '@backstage/plugin-catalog'; import { useEntity } from '@backstage/plugin-catalog-react'; import { isPluginApplicableToEntity as isCircleCIAvailable, @@ -177,6 +181,9 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( )} + + + {isGitHubAvailable(entity) && ( <> diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 01828b1405..6f45f9ba32 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -22,6 +22,7 @@ import { samlAuthApiRef, microsoftAuthApiRef, oneloginAuthApiRef, + oauth2ApiRef, oidcAuthApiRef, } from '@backstage/core'; @@ -32,6 +33,12 @@ export const providers = [ message: 'Sign In using OpenId Connect', apiRef: oidcAuthApiRef, }, + { + id: 'oauth2-auth-provider', + title: 'OAuth 2.0', + message: 'Sign In using OAuth 2.0', + apiRef: oauth2ApiRef, + }, { id: 'google-auth-provider', title: 'Google', diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index cc33a7e81b..42f1fa4a42 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/backend-common +## 0.5.3 + +### Patch Changes + +- ffffea8e6: Minor updates to reflect the changes in `@backstage/integration` that made the fields `apiBaseUrl` and `apiUrl` mandatory. +- 82b2c11b6: Set explicit content-type in error handler responses. +- 965e200c6: Slight refactoring in support of a future search implementation in `UrlReader`. Mostly moving code around. +- 5a5163519: Implement `UrlReader.search` which implements glob matching. +- Updated dependencies [ffffea8e6] + - @backstage/integration@0.4.0 + ## 0.5.2 ### Patch Changes diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 895d241c6a..7b1c6870ab 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.5.2", + "version": "0.5.3", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -32,7 +32,7 @@ "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.2", "@backstage/config-loader": "^0.5.1", - "@backstage/integration": "^0.3.2", + "@backstage/integration": "^0.4.0", "@octokit/rest": "^18.0.12", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", @@ -69,7 +69,7 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/test-utils": "^0.1.5", + "@backstage/test-utils": "^0.1.7", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index c937f8a40c..b52097baf5 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -14,18 +14,22 @@ * limitations under the License. */ -import * as os from 'os'; +import { ConfigReader } from '@backstage/config'; +import { + AzureIntegration, + readAzureIntegrationConfig, +} from '@backstage/integration'; +import { msw } from '@backstage/test-utils'; import fs from 'fs-extra'; import mockFs from 'mock-fs'; -import path from 'path'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { ConfigReader } from '@backstage/config'; +import * as os from 'os'; +import path from 'path'; +import { NotModifiedError } from '../errors'; import { getVoidLogger } from '../logging'; import { AzureUrlReader } from './AzureUrlReader'; -import { msw } from '@backstage/test-utils'; import { ReadTreeResponseFactory } from './tree'; -import { NotModifiedError } from '../errors'; const logger = getVoidLogger(); @@ -36,6 +40,16 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; describe('AzureUrlReader', () => { + beforeEach(() => { + mockFs({ + [tmpDir]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + const worker = setupServer(); msw.setupDefaultHandlers(worker); @@ -143,22 +157,18 @@ describe('AzureUrlReader', () => { }); describe('readTree', () => { - beforeEach(() => { - mockFs({ - [tmpDir]: mockFs.directory(), - }); - }); - - afterEach(() => { - mockFs.restore(); - }); - const repoBuffer = fs.readFileSync( path.resolve('src', 'reading', '__fixtures__', 'mock-main.zip'), ); const processor = new AzureUrlReader( - { host: 'dev.azure.com' }, + new AzureIntegration( + readAzureIntegrationConfig( + new ConfigReader({ + host: 'dev.azure.com', + }), + ), + ), { treeResponseFactory }, ); @@ -257,4 +267,79 @@ describe('AzureUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); }); + + describe('search', () => { + const repoBuffer = fs.readFileSync( + path.resolve('src', 'reading', '__fixtures__', 'mock-main.zip'), + ); + + const processor = new AzureUrlReader( + new AzureIntegration( + readAzureIntegrationConfig( + new ConfigReader({ + host: 'dev.azure.com', + }), + ), + ), + { treeResponseFactory }, + ); + + beforeEach(() => { + worker.use( + rest.get( + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.body(repoBuffer), + ), + ), + rest.get( + // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/commits/get%20commits?view=azure-devops-rest-6.0#on-a-branch + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/commits', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + count: 2, + value: [ + { + commitId: '123abc2', + comment: 'second commit', + }, + { + commitId: '123abc1', + comment: 'first commit', + }, + ], + }), + ), + ), + ); + }); + + it('works for the naive case', async () => { + const result = await processor.search( + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=%2F**%2Findex.*&version=GBmaster', + ); + expect(result.etag).toBe('123abc2'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=%2Fdocs%2Findex.md&version=GBmaster', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('throws NotModifiedError when same etag', async () => { + await expect( + processor.search( + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=**/index.*&version=GBmaster', + { etag: '123abc2' }, + ), + ).rejects.toThrow(NotModifiedError); + }); + }); }); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index bc1b61a041..21b988e5b0 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -15,39 +15,41 @@ */ import { - AzureIntegrationConfig, - readAzureIntegrationConfigs, - getAzureFileFetchUrl, - getAzureDownloadUrl, - getAzureRequestOptions, + AzureIntegration, getAzureCommitsUrl, + getAzureDownloadUrl, + getAzureFileFetchUrl, + getAzureRequestOptions, + ScmIntegrations, } from '@backstage/integration'; import fetch from 'cross-fetch'; +import parseGitUrl from 'git-url-parse'; +import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '../errors'; +import { ReadTreeResponseFactory } from './tree'; +import { stripFirstDirectoryFromPath } from './tree/util'; import { ReaderFactory, ReadTreeOptions, ReadTreeResponse, + SearchOptions, SearchResponse, UrlReader, } from './types'; -import { ReadTreeResponseFactory } from './tree'; export class AzureUrlReader implements UrlReader { static factory: ReaderFactory = ({ config, treeResponseFactory }) => { - const configs = readAzureIntegrationConfigs( - config.getOptionalConfigArray('integrations.azure') ?? [], - ); - return configs.map(options => { - const reader = new AzureUrlReader(options, { treeResponseFactory }); - const predicate = (url: URL) => url.host === options.host; + const integrations = ScmIntegrations.fromConfig(config); + return integrations.azure.list().map(integration => { + const reader = new AzureUrlReader(integration, { treeResponseFactory }); + const predicate = (url: URL) => url.host === integration.config.host; return { reader, predicate }; }); }; constructor( - private readonly options: AzureIntegrationConfig, + private readonly integration: AzureIntegration, private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, ) {} @@ -56,7 +58,10 @@ export class AzureUrlReader implements UrlReader { let response: Response; try { - response = await fetch(builtUrl, getAzureRequestOptions(this.options)); + response = await fetch( + builtUrl, + getAzureRequestOptions(this.integration.config), + ); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -83,7 +88,7 @@ export class AzureUrlReader implements UrlReader { const commitsAzureResponse = await fetch( getAzureCommitsUrl(url), - getAzureRequestOptions(this.options), + getAzureRequestOptions(this.integration.config), ); if (!commitsAzureResponse.ok) { const message = `Failed to read tree from ${url}, ${commitsAzureResponse.status} ${commitsAzureResponse.statusText}`; @@ -100,7 +105,9 @@ export class AzureUrlReader implements UrlReader { const archiveAzureResponse = await fetch( getAzureDownloadUrl(url), - getAzureRequestOptions(this.options, { Accept: 'application/zip' }), + getAzureRequestOptions(this.integration.config, { + Accept: 'application/zip', + }), ); if (!archiveAzureResponse.ok) { const message = `Failed to read tree from ${url}, ${archiveAzureResponse.status} ${archiveAzureResponse.statusText}`; @@ -117,12 +124,38 @@ export class AzureUrlReader implements UrlReader { }); } - async search(): Promise { - throw new Error('AzureUrlReader does not implement search'); + async search(url: string, options?: SearchOptions): Promise { + const { filepath } = parseGitUrl(url); + const matcher = new Minimatch(filepath); + + // TODO(freben): For now, read the entire repo and filter through that. In + // a future improvement, we could be smart and try to deduce that non-glob + // prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used + // to get just that part of the repo. + const treeUrl = new URL(url); + treeUrl.searchParams.delete('path'); + treeUrl.pathname = treeUrl.pathname.replace(/\/+$/, ''); + + const tree = await this.readTree(treeUrl.toString(), { + etag: options?.etag, + filter: path => matcher.match(stripFirstDirectoryFromPath(path)), + }); + const files = await tree.files(); + + return { + etag: tree.etag, + files: files.map(file => ({ + url: this.integration.resolveUrl({ + url: `/${file.path}`, + base: url, + }), + content: file.content, + })), + }; } toString() { - const { host, token } = this.options; + const { host, token } = this.integration.config; return `azure{host=${host},authed=${Boolean(token)}}`; } } diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index 974c84b2c2..df9febb352 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -15,11 +15,16 @@ */ import { ConfigReader } from '@backstage/config'; +import { + BitbucketIntegration, + readBitbucketIntegrationConfig, +} from '@backstage/integration'; import { msw } from '@backstage/test-utils'; import fs from 'fs-extra'; import mockFs from 'mock-fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import os from 'os'; import path from 'path'; import { NotModifiedError } from '../errors'; import { BitbucketUrlReader } from './BitbucketUrlReader'; @@ -30,19 +35,45 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ }); const bitbucketProcessor = new BitbucketUrlReader( - { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, + new BitbucketIntegration( + readBitbucketIntegrationConfig( + new ConfigReader({ + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }), + ), + ), { treeResponseFactory }, ); const hostedBitbucketProcessor = new BitbucketUrlReader( - { - host: 'bitbucket.mycompany.net', - apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', - }, + new BitbucketIntegration( + readBitbucketIntegrationConfig( + new ConfigReader({ + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }), + ), + ), { treeResponseFactory }, ); +const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + describe('BitbucketUrlReader', () => { + beforeEach(() => { + mockFs({ + [tmpDir]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + const worker = setupServer(); + msw.setupDefaultHandlers(worker); + describe('implementation', () => { it('rejects unknown targets', async () => { await expect( @@ -54,19 +85,6 @@ describe('BitbucketUrlReader', () => { }); describe('readTree', () => { - beforeEach(() => { - mockFs({ - '/tmp': mockFs.directory(), - }); - }); - - afterEach(() => { - mockFs.restore(); - }); - - const worker = setupServer(); - msw.setupDefaultHandlers(worker); - const repoBuffer = fs.readFileSync( path.resolve( 'src', @@ -247,12 +265,153 @@ describe('BitbucketUrlReader', () => { expect(() => { /* eslint-disable no-new */ new BitbucketUrlReader( - { - host: 'bitbucket.mycompany.net', - }, + new BitbucketIntegration( + readBitbucketIntegrationConfig( + new ConfigReader({ + host: 'bitbucket.mycompany.net', + }), + ), + ), { treeResponseFactory }, ); }).toThrowError('must configure an explicit apiBaseUrl'); }); }); + + describe('search hosted', () => { + const repoBuffer = fs.readFileSync( + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'bitbucket-repo-with-commit-hash.zip', + ), + ); + + beforeEach(() => { + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + mainbranch: { + type: 'branch', + name: 'master', + }, + }), + ), + ), + rest.get( + 'https://bitbucket.org/backstage/mock/get/master.zip', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-12ab34cd56ef.zip', + ), + ctx.body(repoBuffer), + ), + ), + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), + ); + }); + + it('works for the naive case', async () => { + const result = await bitbucketProcessor.search( + 'https://bitbucket.org/backstage/mock/src/master/**/index.*', + ); + expect(result.etag).toBe('12ab34cd56ef'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://bitbucket.org/backstage/mock/src/master/docs/index.md', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('throws NotModifiedError when same etag', async () => { + await expect( + bitbucketProcessor.search( + 'https://bitbucket.org/backstage/mock/src/master/**/index.*', + { etag: '12ab34cd56ef' }, + ), + ).rejects.toThrow(NotModifiedError); + }); + }); + + describe('search private', () => { + const privateBitbucketRepoBuffer = fs.readFileSync( + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'bitbucket-server-repo.zip', + ), + ); + + beforeEach(() => { + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock.zip', + ), + ctx.body(privateBitbucketRepoBuffer), + ), + ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), + ); + }); + + it('works for the naive case', async () => { + const result = await hostedBitbucketProcessor.search( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master', + ); + expect(result.etag).toBe('12ab34cd56ef'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('throws NotModifiedError when same etag', async () => { + await expect( + hostedBitbucketProcessor.search( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master', + { etag: '12ab34cd56ef' }, + ), + ).rejects.toThrow(NotModifiedError); + }); + }); }); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index e51c03e562..3954e7ee4a 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -15,22 +15,25 @@ */ import { - BitbucketIntegrationConfig, + BitbucketIntegration, getBitbucketDefaultBranch, getBitbucketDownloadUrl, getBitbucketFileFetchUrl, getBitbucketRequestOptions, - readBitbucketIntegrationConfigs, + ScmIntegrations, } from '@backstage/integration'; import fetch from 'cross-fetch'; import parseGitUrl from 'git-url-parse'; +import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '../errors'; import { ReadTreeResponseFactory } from './tree'; +import { stripFirstDirectoryFromPath } from './tree/util'; import { ReaderFactory, ReadTreeOptions, ReadTreeResponse, + SearchOptions, SearchResponse, UrlReader, } from './types'; @@ -40,45 +43,43 @@ import { * the one exposed by Bitbucket Cloud itself. */ export class BitbucketUrlReader implements UrlReader { - private readonly config: BitbucketIntegrationConfig; - private readonly treeResponseFactory: ReadTreeResponseFactory; - static factory: ReaderFactory = ({ config, treeResponseFactory }) => { - const configs = readBitbucketIntegrationConfigs( - config.getOptionalConfigArray('integrations.bitbucket') ?? [], - ); - return configs.map(provider => { - const reader = new BitbucketUrlReader(provider, { treeResponseFactory }); - const predicate = (url: URL) => url.host === provider.host; + const integrations = ScmIntegrations.fromConfig(config); + return integrations.bitbucket.list().map(integration => { + const reader = new BitbucketUrlReader(integration, { + treeResponseFactory, + }); + const predicate = (url: URL) => url.host === integration.config.host; return { reader, predicate }; }); }; constructor( - config: BitbucketIntegrationConfig, - deps: { treeResponseFactory: ReadTreeResponseFactory }, + private readonly integration: BitbucketIntegration, + private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, ) { - const { host, apiBaseUrl, token, username, appPassword } = config; + const { + host, + apiBaseUrl, + token, + username, + appPassword, + } = integration.config; if (!apiBaseUrl) { throw new Error( `Bitbucket integration for '${host}' must configure an explicit apiBaseUrl`, ); - } - - if (!token && username && !appPassword) { + } else if (!token && username && !appPassword) { throw new Error( `Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`, ); } - - this.config = config; - this.treeResponseFactory = deps.treeResponseFactory; } async read(url: string): Promise { - const bitbucketUrl = getBitbucketFileFetchUrl(url, this.config); - const options = getBitbucketRequestOptions(this.config); + const bitbucketUrl = getBitbucketFileFetchUrl(url, this.integration.config); + const options = getBitbucketRequestOptions(this.integration.config); let response: Response; try { @@ -109,10 +110,13 @@ export class BitbucketUrlReader implements UrlReader { throw new NotModifiedError(); } - const downloadUrl = await getBitbucketDownloadUrl(url, this.config); + const downloadUrl = await getBitbucketDownloadUrl( + url, + this.integration.config, + ); const archiveBitbucketResponse = await fetch( downloadUrl, - getBitbucketRequestOptions(this.config), + getBitbucketRequestOptions(this.integration.config), ); if (!archiveBitbucketResponse.ok) { const message = `Failed to read tree from ${url}, ${archiveBitbucketResponse.status} ${archiveBitbucketResponse.statusText}`; @@ -122,7 +126,7 @@ export class BitbucketUrlReader implements UrlReader { throw new Error(message); } - return await this.treeResponseFactory.fromZipArchive({ + return await this.deps.treeResponseFactory.fromZipArchive({ stream: (archiveBitbucketResponse.body as unknown) as Readable, subpath: filepath, etag: lastCommitShortHash, @@ -130,12 +134,36 @@ export class BitbucketUrlReader implements UrlReader { }); } - async search(): Promise { - throw new Error('BitbucketUrlReader does not implement search'); + async search(url: string, options?: SearchOptions): Promise { + const { filepath } = parseGitUrl(url); + const matcher = new Minimatch(filepath); + + // TODO(freben): For now, read the entire repo and filter through that. In + // a future improvement, we could be smart and try to deduce that non-glob + // prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used + // to get just that part of the repo. + const treeUrl = url.replace(filepath, '').replace(/\/+$/, ''); + + const tree = await this.readTree(treeUrl, { + etag: options?.etag, + filter: path => matcher.match(stripFirstDirectoryFromPath(path)), + }); + const files = await tree.files(); + + return { + etag: tree.etag, + files: files.map(file => ({ + url: this.integration.resolveUrl({ + url: `/${file.path}`, + base: url, + }), + content: file.content, + })), + }; } toString() { - const { host, token, username, appPassword } = this.config; + const { host, token, username, appPassword } = this.integration.config; let authed = Boolean(token); if (!authed) { authed = Boolean(username && appPassword); @@ -148,18 +176,18 @@ export class BitbucketUrlReader implements UrlReader { let branch = ref; if (!branch) { - branch = await getBitbucketDefaultBranch(url, this.config); + branch = await getBitbucketDefaultBranch(url, this.integration.config); } const isHosted = resource === 'bitbucket.org'; // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp222 const commitsApiUrl = isHosted - ? `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}` - : `${this.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`; + ? `${this.integration.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}` + : `${this.integration.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`; const commitsResponse = await fetch( commitsApiUrl, - getBitbucketRequestOptions(this.config), + getBitbucketRequestOptions(this.integration.config), ); if (!commitsResponse.ok) { const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`; diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index d4e579b143..72fbf83f6d 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -15,12 +15,17 @@ */ import { ConfigReader } from '@backstage/config'; -import { GithubCredentialsProvider } from '@backstage/integration'; +import { + GithubCredentialsProvider, + GitHubIntegration, + readGitHubIntegrationConfig, +} from '@backstage/integration'; import { msw } from '@backstage/test-utils'; import fs from 'fs-extra'; import mockFs from 'mock-fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import os from 'os'; import path from 'path'; import { NotFoundError, NotModifiedError } from '../errors'; import { @@ -41,26 +46,45 @@ const mockCredentialsProvider = ({ } as unknown) as GithubCredentialsProvider; const githubProcessor = new GithubUrlReader( - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }, + new GitHubIntegration( + readGitHubIntegrationConfig( + new ConfigReader({ + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }), + ), + ), { treeResponseFactory, credentialsProvider: mockCredentialsProvider }, ); const gheProcessor = new GithubUrlReader( - { - host: 'ghe.github.com', - apiBaseUrl: 'https://ghe.github.com/api/v3', - }, + new GitHubIntegration( + readGitHubIntegrationConfig( + new ConfigReader({ + host: 'ghe.github.com', + apiBaseUrl: 'https://ghe.github.com/api/v3', + }), + ), + ), { treeResponseFactory, credentialsProvider: mockCredentialsProvider }, ); +const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + describe('GithubUrlReader', () => { const worker = setupServer(); - msw.setupDefaultHandlers(worker); + beforeEach(() => { + mockFs({ + [tmpDir]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + beforeEach(() => { jest.clearAllMocks(); }); @@ -94,7 +118,7 @@ describe('GithubUrlReader', () => { worker.use( rest.get( - 'https://api.github.com/repos/backstage/mock/tree/contents/?ref=main', + 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/?ref=main', (req, res, ctx) => { expect(req.headers.get('authorization')).toBe( mockHeaders.Authorization, @@ -111,7 +135,7 @@ describe('GithubUrlReader', () => { ), ); - await githubProcessor.read( + await gheProcessor.read( 'https://github.com/backstage/mock/tree/blob/main', ); }); @@ -122,16 +146,6 @@ describe('GithubUrlReader', () => { */ describe('readTree', () => { - beforeEach(() => { - mockFs({ - '/tmp': mockFs.directory(), - }); - }); - - afterEach(() => { - mockFs.restore(); - }); - const repoBuffer = fs.readFileSync( path.resolve( 'src', @@ -397,9 +411,13 @@ describe('GithubUrlReader', () => { expect(() => { /* eslint-disable no-new */ new GithubUrlReader( - { - host: 'ghe.mycompany.net', - }, + new GitHubIntegration( + readGitHubIntegrationConfig( + new ConfigReader({ + host: 'ghe.mycompany.net', + }), + ), + ), { treeResponseFactory, credentialsProvider: mockCredentialsProvider, @@ -414,14 +432,6 @@ describe('GithubUrlReader', () => { */ describe('search', () => { - beforeEach(() => { - mockFs({ '/tmp': mockFs.directory() }); - }); - - afterEach(() => { - mockFs.restore(); - }); - const repoBuffer = fs.readFileSync( path.resolve( 'src', diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 291740a9fc..ac5a93bd5f 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -17,8 +17,8 @@ import { getGitHubFileFetchUrl, GithubCredentialsProvider, - GitHubIntegrationConfig, - readGitHubIntegrationConfigs, + GitHubIntegration, + ScmIntegrations, } from '@backstage/integration'; import { RestEndpointMethodTypes } from '@octokit/rest'; import fetch from 'cross-fetch'; @@ -48,36 +48,36 @@ export type GhBlobResponse = RestEndpointMethodTypes['git']['getBlob']['response */ export class GithubUrlReader implements UrlReader { static factory: ReaderFactory = ({ config, treeResponseFactory }) => { - const configs = readGitHubIntegrationConfigs( - config.getOptionalConfigArray('integrations.github') ?? [], - ); - return configs.map(provider => { - const credentialsProvider = GithubCredentialsProvider.create(provider); - const reader = new GithubUrlReader(provider, { + const integrations = ScmIntegrations.fromConfig(config); + return integrations.github.list().map(integration => { + const credentialsProvider = GithubCredentialsProvider.create( + integration.config, + ); + const reader = new GithubUrlReader(integration, { treeResponseFactory, credentialsProvider, }); - const predicate = (url: URL) => url.host === provider.host; + const predicate = (url: URL) => url.host === integration.config.host; return { reader, predicate }; }); }; constructor( - private readonly config: GitHubIntegrationConfig, + private readonly integration: GitHubIntegration, private readonly deps: { treeResponseFactory: ReadTreeResponseFactory; credentialsProvider: GithubCredentialsProvider; }, ) { - if (!config.apiBaseUrl && !config.rawBaseUrl) { + if (!integration.config.apiBaseUrl && !integration.config.rawBaseUrl) { throw new Error( - `GitHub integration for '${config.host}' must configure an explicit apiBaseUrl and rawBaseUrl`, + `GitHub integration '${integration.title}' must configure an explicit apiBaseUrl or rawBaseUrl`, ); } } async read(url: string): Promise { - const ghUrl = getGitHubFileFetchUrl(url, this.config); + const ghUrl = getGitHubFileFetchUrl(url, this.integration.config); const { headers } = await this.deps.credentialsProvider.getCredentials({ url, }); @@ -155,7 +155,7 @@ export class GithubUrlReader implements UrlReader { } toString() { - const { host, token } = this.config; + const { host, token } = this.integration.config; return `github{host=${host},authed=${Boolean(token)}}`; } @@ -258,7 +258,7 @@ export class GithubUrlReader implements UrlReader { }); const repo: GhRepoResponse = await this.fetchJson( - `${this.config.apiBaseUrl}/repos/${full_name}`, + `${this.integration.config.apiBaseUrl}/repos/${full_name}`, { headers }, ); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index b18e7a4294..636540caa8 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -20,11 +20,16 @@ import fs from 'fs-extra'; import mockFs from 'mock-fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import os from 'os'; import path from 'path'; import { getVoidLogger } from '../logging'; import { GitlabUrlReader } from './GitlabUrlReader'; import { ReadTreeResponseFactory } from './tree'; import { NotModifiedError, NotFoundError } from '../errors'; +import { + GitLabIntegration, + readGitLabIntegrationConfig, +} from '@backstage/integration'; const logger = getVoidLogger(); @@ -33,22 +38,44 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ }); const gitlabProcessor = new GitlabUrlReader( - { - host: 'gitlab.com', - apiBaseUrl: 'https://gitlab.com/api/v4', - }, + new GitLabIntegration( + readGitLabIntegrationConfig( + new ConfigReader({ + host: 'gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', + baseUrl: 'https://gitlab.com', + }), + ), + ), { treeResponseFactory }, ); const hostedGitlabProcessor = new GitlabUrlReader( - { - host: 'gitlab.mycompany.com', - apiBaseUrl: 'https://gitlab.mycompany.com/api/v4', - }, + new GitLabIntegration( + readGitLabIntegrationConfig( + new ConfigReader({ + host: 'gitlab.mycompany.com', + apiBaseUrl: 'https://gitlab.mycompany.com/api/v4', + baseUrl: 'https://gitlab.mycompany.com', + }), + ), + ), { treeResponseFactory }, ); +const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + describe('GitlabUrlReader', () => { + beforeEach(() => { + mockFs({ + [tmpDir]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + const worker = setupServer(); msw.setupDefaultHandlers(worker); @@ -154,16 +181,6 @@ describe('GitlabUrlReader', () => { }); describe('readTree', () => { - beforeEach(() => { - mockFs({ - '/tmp': mockFs.directory(), - }); - }); - - afterEach(() => { - mockFs.restore(); - }); - const archiveBuffer = fs.readFileSync( path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'), ); @@ -379,17 +396,81 @@ describe('GitlabUrlReader', () => { }; await expect(fnGithub).rejects.toThrow(NotFoundError); }); + }); - it('should throw error when apiBaseUrl is missing', () => { - expect(() => { - /* eslint-disable no-new */ - new GitlabUrlReader( - { - host: 'gitlab.mycompany.com', - }, - { treeResponseFactory }, - ); - }).toThrowError('must configure an explicit apiBaseUrl'); + describe('search', () => { + const archiveBuffer = fs.readFileSync( + path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'), + ); + + const projectGitlabApiResponse = { + id: 11111111, + default_branch: 'main', + }; + + const branchGitlabApiResponse = { + commit: { + id: 'sha123abc', + }, + }; + + beforeEach(() => { + worker.use( + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename="mock-main-sha123abc.zip"', + ), + ctx.body(archiveBuffer), + ), + ), + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(projectGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(branchGitlabApiResponse), + ), + ), + ); + }); + + it('works for the naive case', async () => { + const result = await gitlabProcessor.search( + 'https://gitlab.com/backstage/mock/tree/main/**/index.*', + ); + expect(result.etag).toBe('sha123abc'); + expect(result.files.length).toBe(1); + expect(result.files[0].url).toBe( + 'https://gitlab.com/backstage/mock/tree/main/docs/index.md', + ); + await expect(result.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + }); + + it('throws NotModifiedError when same etag', async () => { + await expect( + gitlabProcessor.search( + 'https://gitlab.com/backstage/mock/tree/main/**/index.*', + { etag: 'sha123abc' }, + ), + ).rejects.toThrow(NotModifiedError); }); }); }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 5e02a9b8e6..66ceae05c6 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -17,55 +17,51 @@ import { getGitLabFileFetchUrl, getGitLabRequestOptions, - GitLabIntegrationConfig, - readGitLabIntegrationConfigs, + GitLabIntegration, + ScmIntegrations, } from '@backstage/integration'; import fetch from 'cross-fetch'; import parseGitUrl from 'git-url-parse'; +import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '../errors'; import { ReadTreeResponseFactory } from './tree'; +import { stripFirstDirectoryFromPath } from './tree/util'; import { ReaderFactory, ReadTreeOptions, ReadTreeResponse, + SearchOptions, SearchResponse, UrlReader, } from './types'; export class GitlabUrlReader implements UrlReader { - private readonly treeResponseFactory: ReadTreeResponseFactory; - static factory: ReaderFactory = ({ config, treeResponseFactory }) => { - const configs = readGitLabIntegrationConfigs( - config.getOptionalConfigArray('integrations.gitlab') ?? [], - ); - return configs.map(provider => { - const reader = new GitlabUrlReader(provider, { treeResponseFactory }); - const predicate = (url: URL) => url.host === provider.host; + const integrations = ScmIntegrations.fromConfig(config); + return integrations.gitlab.list().map(integration => { + const reader = new GitlabUrlReader(integration, { + treeResponseFactory, + }); + const predicate = (url: URL) => url.host === integration.config.host; return { reader, predicate }; }); }; constructor( - private readonly config: GitLabIntegrationConfig, - deps: { treeResponseFactory: ReadTreeResponseFactory }, - ) { - this.treeResponseFactory = deps.treeResponseFactory; - - if (!config.apiBaseUrl) { - throw new Error( - `GitLab integration for '${config.host}' must configure an explicit apiBaseUrl`, - ); - } - } + private readonly integration: GitLabIntegration, + private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, + ) {} async read(url: string): Promise { - const builtUrl = await getGitLabFileFetchUrl(url, this.config); + const builtUrl = await getGitLabFileFetchUrl(url, this.integration.config); let response: Response; try { - response = await fetch(builtUrl, getGitLabRequestOptions(this.config)); + response = await fetch( + builtUrl, + getGitLabRequestOptions(this.integration.config), + ); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -92,9 +88,11 @@ export class GitlabUrlReader implements UrlReader { // https://docs.gitlab.com/ee/api/README.html#namespaced-path-encoding const projectGitlabResponse = await fetch( new URL( - `${this.config.apiBaseUrl}/projects/${encodeURIComponent(full_name)}`, + `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( + full_name, + )}`, ).toString(), - getGitLabRequestOptions(this.config), + getGitLabRequestOptions(this.integration.config), ); if (!projectGitlabResponse.ok) { const msg = `Failed to read tree from ${url}, ${projectGitlabResponse.status} ${projectGitlabResponse.statusText}`; @@ -112,11 +110,11 @@ export class GitlabUrlReader implements UrlReader { // the provided sha. const branchGitlabResponse = await fetch( new URL( - `${this.config.apiBaseUrl}/projects/${encodeURIComponent( + `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( full_name, )}/repository/branches/${branch}`, ).toString(), - getGitLabRequestOptions(this.config), + getGitLabRequestOptions(this.integration.config), ); if (!branchGitlabResponse.ok) { const message = `Failed to read tree (branch) from ${url}, ${branchGitlabResponse.status} ${branchGitlabResponse.statusText}`; @@ -134,10 +132,10 @@ export class GitlabUrlReader implements UrlReader { // https://docs.gitlab.com/ee/api/repositories.html#get-file-archive const archiveGitLabResponse = await fetch( - `${this.config.apiBaseUrl}/projects/${encodeURIComponent( + `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( full_name, )}/repository/archive.zip?sha=${branch}`, - getGitLabRequestOptions(this.config), + getGitLabRequestOptions(this.integration.config), ); if (!archiveGitLabResponse.ok) { const message = `Failed to read tree (archive) from ${url}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`; @@ -147,7 +145,7 @@ export class GitlabUrlReader implements UrlReader { throw new Error(message); } - return await this.treeResponseFactory.fromZipArchive({ + return await this.deps.treeResponseFactory.fromZipArchive({ stream: (archiveGitLabResponse.body as unknown) as Readable, subpath: filepath, etag: commitSha, @@ -155,12 +153,33 @@ export class GitlabUrlReader implements UrlReader { }); } - async search(): Promise { - throw new Error('GitlabUrlReader does not implement search'); + async search(url: string, options?: SearchOptions): Promise { + const { filepath } = parseGitUrl(url); + const matcher = new Minimatch(filepath); + + // TODO(freben): For now, read the entire repo and filter through that. In + // a future improvement, we could be smart and try to deduce that non-glob + // prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used + // to get just that part of the repo. + const treeUrl = url.replace(filepath, '').replace(/\/+$/, ''); + + const tree = await this.readTree(treeUrl, { + etag: options?.etag, + filter: path => matcher.match(stripFirstDirectoryFromPath(path)), + }); + const files = await tree.files(); + + return { + etag: tree.etag, + files: files.map(file => ({ + url: this.integration.resolveUrl({ url: `/${file.path}`, base: url }), + content: file.content, + })), + }; } toString() { - const { host, token } = this.config; + const { host, token } = this.integration.config; return `gitlab{host=${host},authed=${Boolean(token)}}`; } } diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts index d22529293f..e61a1df645 100644 --- a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts @@ -25,15 +25,12 @@ import { ReadTreeResponseDirOptions, ReadTreeResponseFile, } from '../types'; +import { stripFirstDirectoryFromPath } from './util'; // Tar types for `Parse` is not a proper constructor, but it should be const TarParseStream = (Parse as unknown) as { new (): ParseStream }; const pipeline = promisify(pipelineCb); -// Matches a directory name + one `/` at the start of any string, -// containing any character except `/` one or more times, and ending with a `/` -// e.g. Will match `dirA/` in `dirA/dirB/file.ext` -const directoryNameRegex = /^[^\/]+\//; /** * Wraps a tar archive stream into a tree response reader. @@ -84,7 +81,7 @@ export class TarArchiveResponse implements ReadTreeResponse { // File path relative to the root extracted directory. Will remove the // top level dir name from the path since its name is hard to predetermine. - const relativePath = entry.path.replace(directoryNameRegex, ''); + const relativePath = stripFirstDirectoryFromPath(entry.path); if (this.subPath) { if (!relativePath.startsWith(this.subPath)) { @@ -161,7 +158,7 @@ export class TarArchiveResponse implements ReadTreeResponse { filter: path => { // File path relative to the root extracted directory. Will remove the // top level dir name from the path since its name is hard to predetermine. - const relativePath = path.replace(directoryNameRegex, ''); + const relativePath = stripFirstDirectoryFromPath(path); if (this.subPath && !relativePath.startsWith(this.subPath)) { return false; } diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts index 2547c7b0e4..4aebff5c84 100644 --- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts @@ -24,11 +24,7 @@ import { ReadTreeResponseDirOptions, ReadTreeResponseFile, } from '../types'; - -// Matches a directory name + one `/` at the start of any string, -// containing any character except / one or more times, and ending with a `/` -// e.g. Will match `dirA/` in `dirA/dirB/file.ext` -const directoryNameRegex = /^[^\/]+\//; +import { stripFirstDirectoryFromPath } from './util'; /** * Wraps a zip archive stream into a tree response reader. @@ -65,18 +61,13 @@ export class ZipArchiveResponse implements ReadTreeResponse { this.read = true; } - // Will remove the top level dir name from the path since its name is hard to predetermine. - private stripTopDirectory(path: string): string { - return path.replace(directoryNameRegex, ''); - } - // File path relative to the root extracted directory or a sub directory if subpath is set. private getInnerPath(path: string): string { return path.slice(this.subPath.length); } private shouldBeIncluded(entry: Entry): boolean { - const strippedPath = this.stripTopDirectory(entry.path); + const strippedPath = stripFirstDirectoryFromPath(entry.path); if (this.subPath) { if (!strippedPath.startsWith(this.subPath)) { @@ -104,7 +95,7 @@ export class ZipArchiveResponse implements ReadTreeResponse { if (this.shouldBeIncluded(entry)) { files.push({ - path: this.getInnerPath(this.stripTopDirectory(entry.path)), + path: this.getInnerPath(stripFirstDirectoryFromPath(entry.path)), content: () => entry.buffer(), }); } else { @@ -153,7 +144,7 @@ export class ZipArchiveResponse implements ReadTreeResponse { // as a zip can have files with directories without directory entries if (entry.type === 'File' && this.shouldBeIncluded(entry)) { const entryPath = this.getInnerPath( - this.stripTopDirectory(entry.path), + stripFirstDirectoryFromPath(entry.path), ); const dirname = platformPath.dirname(entryPath); if (dirname) { diff --git a/packages/backend-common/src/reading/tree/util.ts b/packages/backend-common/src/reading/tree/util.ts new file mode 100644 index 0000000000..8e908a5e60 --- /dev/null +++ b/packages/backend-common/src/reading/tree/util.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Matches a directory name + one `/` at the start of any string, +// containing any character except `/` one or more times, and ending with a `/` +// e.g. Will match `dirA/` in `dirA/dirB/file.ext` +const directoryNameRegex = /^[^\/]+\//; + +// Removes the first segment of a forward-slash-separated path +export function stripFirstDirectoryFromPath(path: string): string { + return path.replace(directoryNameRegex, ''); +} diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 9c5ac20fa3..07da975184 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -161,7 +161,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { app.use(cors(corsOptions)); } app.use(compression()); - app.use(requestLoggingHandler()); + app.use(requestLoggingHandler(logger)); for (const [root, route] of this.routers) { app.use(root, route); } diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index f85c22784d..05549b2085 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,34 @@ # example-backend +## 0.2.15 + +### Patch Changes + +- Updated dependencies [1deb31141] +- Updated dependencies [6ed2b47d6] +- Updated dependencies [77ad0003a] +- Updated dependencies [d2441aee3] +- Updated dependencies [727f0deec] +- Updated dependencies [fb53eb7cb] +- Updated dependencies [07bafa248] +- Updated dependencies [ffffea8e6] +- Updated dependencies [f3fbfb452] +- Updated dependencies [615103a63] +- Updated dependencies [84364b35c] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [5a5163519] +- Updated dependencies [82b2c11b6] +- Updated dependencies [08142b256] +- Updated dependencies [08142b256] + - @backstage/plugin-auth-backend@0.3.0 + - @backstage/plugin-scaffolder-backend@0.7.0 + - @backstage/plugin-catalog-backend@0.6.1 + - @backstage/plugin-app-backend@0.3.7 + - example-app@0.2.15 + - @backstage/backend-common@0.5.3 + - @backstage/plugin-techdocs-backend@0.6.0 + ## 0.2.14 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index 544bb976fe..e5271f0109 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.14", + "version": "0.2.15", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,24 +27,24 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.5.2", + "@backstage/backend-common": "^0.5.3", "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@backstage/plugin-app-backend": "^0.3.6", - "@backstage/plugin-auth-backend": "^0.2.12", - "@backstage/plugin-catalog-backend": "^0.6.0", + "@backstage/plugin-app-backend": "^0.3.7", + "@backstage/plugin-auth-backend": "^0.3.0", + "@backstage/plugin-catalog-backend": "^0.6.1", "@backstage/plugin-graphql-backend": "^0.1.5", "@backstage/plugin-kubernetes-backend": "^0.2.6", "@backstage/plugin-kafka-backend": "^0.2.0", "@backstage/plugin-proxy-backend": "^0.2.4", "@backstage/plugin-rollbar-backend": "^0.1.7", - "@backstage/plugin-scaffolder-backend": "^0.6.0", - "@backstage/plugin-techdocs-backend": "^0.5.5", + "@backstage/plugin-scaffolder-backend": "^0.7.0", + "@backstage/plugin-techdocs-backend": "^0.6.0", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.0.12", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.1", - "example-app": "^0.2.14", + "example-app": "^0.2.15", "express": "^4.17.1", "express-promise-router": "^3.0.3", "knex": "^0.21.6", diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index c8042b72e9..87d6e2cff0 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/catalog-client +## 0.3.6 + +### Patch Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- 72b96e880: Add the `presence` argument to the `CatalogApi` to be able to register optional locations. + ## 0.3.5 ### Patch Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 00b05e37bf..1d0e890f4e 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "0.3.5", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 6369f95b76..19da45e4ec 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -21,6 +21,7 @@ import { CatalogClient } from './CatalogClient'; import { CatalogListResponse, DiscoveryApi } from './types'; const server = setupServer(); +const token = 'fake-token'; const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; const discoveryApi: DiscoveryApi = { async getBaseUrl(_pluginId) { @@ -71,7 +72,7 @@ describe('CatalogClient', () => { }); it('should entities from correct endpoint', async () => { - const response = await client.getEntities(); + const response = await client.getEntities({}, { token }); expect(response).toEqual(defaultResponse); }); @@ -85,13 +86,16 @@ describe('CatalogClient', () => { }), ); - const response = await client.getEntities({ - filter: { - a: '1', - b: ['2', '3'], - ö: '=', + const response = await client.getEntities( + { + filter: { + a: '1', + b: ['2', '3'], + ö: '=', + }, }, - }); + { token }, + ); expect(response.items).toEqual([]); }); @@ -106,11 +110,61 @@ describe('CatalogClient', () => { }), ); - const response = await client.getEntities({ - fields: ['a.b', 'ö'], - }); + const response = await client.getEntities( + { + fields: ['a.b', 'ö'], + }, + { token }, + ); expect(response.items).toEqual([]); }); }); + + describe('getLocationById', () => { + const defaultResponse = { + data: { + id: '42', + }, + }; + + beforeEach(() => { + server.use( + rest.get(`${mockBaseUrl}/locations/42`, (_, res, ctx) => { + return res(ctx.json(defaultResponse)); + }), + ); + }); + + it('should locations from correct endpoint', async () => { + const response = await client.getLocationById('42', { token }); + expect(response).toEqual(defaultResponse); + }); + + it('forwards authorization token', async () => { + expect.assertions(1); + + server.use( + rest.get(`${mockBaseUrl}/locations/42`, (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe(`Bearer ${token}`); + return res(ctx.json(defaultResponse)); + }), + ); + + await client.getLocationById('42', { token }); + }); + + it('skips authorization header if token is omitted', async () => { + expect.assertions(1); + + server.use( + rest.get(`${mockBaseUrl}/locations/42`, (req, res, ctx) => { + expect(req.headers.get('authorization')).toBeNull(); + return res(ctx.json(defaultResponse)); + }), + ); + + await client.getLocationById('42'); + }); + }); }); diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 362b1e71da..04206edcdb 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -24,6 +24,7 @@ import fetch from 'cross-fetch'; import { AddLocationRequest, AddLocationResponse, + CatalogRequestOptions, CatalogApi, CatalogEntitiesRequest, CatalogListResponse, @@ -37,12 +38,16 @@ export class CatalogClient implements CatalogApi { this.discoveryApi = options.discoveryApi; } - async getLocationById(id: String): Promise { - return await this.getOptional(`/locations/${id}`); + async getLocationById( + id: String, + options?: CatalogRequestOptions, + ): Promise { + return await this.getOptional(`/locations/${id}`, options); } async getEntities( request?: CatalogEntitiesRequest, + options?: CatalogRequestOptions, ): Promise> { const { filter = {}, fields = [] } = request ?? {}; const params: string[] = []; @@ -62,20 +67,28 @@ export class CatalogClient implements CatalogApi { } const query = params.length ? `?${params.join('&')}` : ''; - const entities: Entity[] = await this.getRequired(`/entities${query}`); + const entities: Entity[] = await this.getRequired( + `/entities${query}`, + options, + ); return { items: entities }; } - async getEntityByName(compoundName: EntityName): Promise { + async getEntityByName( + compoundName: EntityName, + options?: CatalogRequestOptions, + ): Promise { const { kind, namespace = 'default', name } = compoundName; - return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`); + return this.getOptional( + `/entities/by-name/${kind}/${namespace}/${name}`, + options, + ); } - async addLocation({ - type = 'url', - target, - dryRun, - }: AddLocationRequest): Promise { + async addLocation( + { type = 'url', target, dryRun, presence }: AddLocationRequest, + options?: CatalogRequestOptions, + ): Promise { const response = await fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/locations${ dryRun ? '?dryRun=true' : '' @@ -83,9 +96,10 @@ export class CatalogClient implements CatalogApi { { headers: { 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), }, method: 'POST', - body: JSON.stringify({ type, target }), + body: JSON.stringify({ type, target, presence }), }, ); @@ -110,18 +124,30 @@ export class CatalogClient implements CatalogApi { }; } - async getLocationByEntity(entity: Entity): Promise { + async getLocationByEntity( + entity: Entity, + options?: CatalogRequestOptions, + ): Promise { const locationCompound = entity.metadata.annotations?.[LOCATION_ANNOTATION]; - const all: { data: Location }[] = await this.getRequired('/locations'); + const all: { data: Location }[] = await this.getRequired( + '/locations', + options, + ); return all .map(r => r.data) .find(l => locationCompound === `${l.type}:${l.target}`); } - async removeEntityByUid(uid: string): Promise { + async removeEntityByUid( + uid: string, + options?: CatalogRequestOptions, + ): Promise { const response = await fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/entities/by-uid/${uid}`, { + headers: options?.token + ? { Authorization: `Bearer ${options.token}` } + : {}, method: 'DELETE', }, ); @@ -138,9 +164,16 @@ export class CatalogClient implements CatalogApi { // Private methods // - private async getRequired(path: string): Promise { + private async getRequired( + path: string, + options?: CatalogRequestOptions, + ): Promise { const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`; - const response = await fetch(url); + const response = await fetch(url, { + headers: options?.token + ? { Authorization: `Bearer ${options.token}` } + : {}, + }); if (!response.ok) { const payload = await response.text(); @@ -151,9 +184,16 @@ export class CatalogClient implements CatalogApi { return await response.json(); } - private async getOptional(path: string): Promise { + private async getOptional( + path: string, + options?: CatalogRequestOptions, + ): Promise { const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`; - const response = await fetch(url); + const response = await fetch(url, { + headers: options?.token + ? { Authorization: `Bearer ${options.token}` } + : {}, + }); if (!response.ok) { if (response.status === 404) { diff --git a/packages/catalog-client/src/index.ts b/packages/catalog-client/src/index.ts index 9bd13f9b7c..c5a626e25b 100644 --- a/packages/catalog-client/src/index.ts +++ b/packages/catalog-client/src/index.ts @@ -15,4 +15,10 @@ */ export { CatalogClient } from './CatalogClient'; -export type { CatalogApi } from './types'; +export type { + AddLocationRequest, + AddLocationResponse, + CatalogApi, + CatalogEntitiesRequest, + CatalogListResponse, +} from './types'; diff --git a/packages/catalog-client/src/types.ts b/packages/catalog-client/src/types.ts index e72317d444..e460843657 100644 --- a/packages/catalog-client/src/types.ts +++ b/packages/catalog-client/src/types.ts @@ -25,21 +25,42 @@ export type CatalogListResponse = { items: T[]; }; +export type CatalogRequestOptions = { + token?: string; +}; + export interface CatalogApi { - getLocationById(id: String): Promise; - getEntityByName(name: EntityName): Promise; + getLocationById( + id: String, + options?: CatalogRequestOptions, + ): Promise; + getEntityByName( + name: EntityName, + options?: CatalogRequestOptions, + ): Promise; getEntities( request?: CatalogEntitiesRequest, + options?: CatalogRequestOptions, ): Promise>; - addLocation(location: AddLocationRequest): Promise; - getLocationByEntity(entity: Entity): Promise; - removeEntityByUid(uid: string): Promise; + addLocation( + location: AddLocationRequest, + options?: CatalogRequestOptions, + ): Promise; + getLocationByEntity( + entity: Entity, + options?: CatalogRequestOptions, + ): Promise; + removeEntityByUid( + uid: string, + options?: CatalogRequestOptions, + ): Promise; } export type AddLocationRequest = { type?: string; target: string; dryRun?: boolean; + presence?: 'optional' | 'required'; }; export type AddLocationResponse = { diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml index fbf28d7fcb..edd9b8fcf9 100644 --- a/packages/catalog-model/examples/components/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml @@ -7,11 +7,27 @@ metadata: - java - data links: - - url: https://example.com/apm/artists-lookup - title: APM + - url: https://example.com/user + title: Examples Users + icon: user + - url: https://example.com/group + title: Example Group + icon: group + - url: https://example.com/cloud + title: Link with Cloud Icon + icon: cloud + - url: https://example.com/dashboard + title: Dashboard icon: dashboard - - url: https://example.com/logs/artists-lookup - title: Logs + - url: https://example.com/help + title: Support + icon: help + - url: https://example.com/web + title: Website + icon: web + - url: https://example.com/alert + title: Alerts + icon: alert spec: type: service lifecycle: experimental diff --git a/packages/cli/package.json b/packages/cli/package.json index 9aaeb00459..dde6bc34ba 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,7 +43,7 @@ "@spotify/eslint-config-react": "^9.0.0", "@spotify/eslint-config-typescript": "^9.0.0", "@sucrase/webpack-loader": "^2.0.0", - "@svgr/plugin-jsx": "5.4.x", + "@svgr/plugin-jsx": "5.5.x", "@svgr/plugin-svgo": "5.4.x", "@svgr/rollup": "5.5.x", "@svgr/webpack": "5.4.x", diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts index f567786b7c..f0867c6408 100644 --- a/packages/cli/src/lib/versioning/Lockfile.ts +++ b/packages/cli/src/lib/versioning/Lockfile.ts @@ -153,7 +153,7 @@ export class Lockfile { const acceptedVersion = versions.find(v => semver.satisfies(v, range)); if (!acceptedVersion) { throw new Error( - `No existing version was accepted for range ${range}, searching through ${versions}`, + `No existing version was accepted for range ${range}, searching through ${versions}, for package ${name}`, ); } diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 2aaeba9796..f50bcdd015 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -48,7 +48,7 @@ import { routeElementDiscoverer, traverseElementTree, } from '../extensions/traversal'; -import { IconComponent, SystemIconKey, SystemIcons } from '../icons'; +import { IconComponent, IconComponentMap, IconKey } from '../icons'; import { BackstagePlugin } from '../plugin'; import { RouteRef } from '../routing'; import { @@ -95,7 +95,7 @@ export function generateBoundRoutes( type FullAppOptions = { apis: Iterable; - icons: SystemIcons; + icons: IconComponentMap; plugins: BackstagePlugin[]; components: AppComponents; themes: AppTheme[]; @@ -144,7 +144,7 @@ export class PrivateAppImpl implements BackstageApp { private configApi?: ConfigApi; private readonly apis: Iterable; - private readonly icons: SystemIcons; + private readonly icons: IconComponentMap; private readonly plugins: BackstagePlugin[]; private readonly components: AppComponents; private readonly themes: AppTheme[]; @@ -169,7 +169,7 @@ export class PrivateAppImpl implements BackstageApp { return this.plugins; } - getSystemIcon(key: SystemIconKey): IconComponent { + getSystemIcon(key: IconKey): IconComponent { return this.icons[key]; } diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index b6b1002d12..1970868ca6 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -15,7 +15,7 @@ */ import { ComponentType } from 'react'; -import { IconComponent, SystemIconKey, SystemIcons } from '../icons'; +import { IconComponent, IconComponentMap, IconKey } from '../icons'; import { BackstagePlugin, AnyExternalRoutes } from '../plugin/types'; import { RouteRef } from '../routing'; import { AnyApiFactory } from '../apis'; @@ -94,7 +94,7 @@ export type AppOptions = { /** * Supply icons to override the default ones. */ - icons?: Partial; + icons?: IconComponentMap; /** * A list of all plugins to include in the app. @@ -169,9 +169,9 @@ export type BackstageApp = { getPlugins(): BackstagePlugin[]; /** - * Get a common icon for this app. + * Get a common or custom icon for this app. */ - getSystemIcon(key: SystemIconKey): IconComponent; + getSystemIcon(key: IconKey): IconComponent; /** * Provider component that should wrap the Router created with getRouter() diff --git a/packages/core-api/src/icons/icons.tsx b/packages/core-api/src/icons/icons.tsx index 50c4b68e43..0c2b580ea7 100644 --- a/packages/core-api/src/icons/icons.tsx +++ b/packages/core-api/src/icons/icons.tsx @@ -15,15 +15,19 @@ */ import { SvgIconProps } from '@material-ui/core'; +import MuiDashboardIcon from '@material-ui/icons/Dashboard'; +import MuiHelpIcon from '@material-ui/icons/Help'; import PeopleIcon from '@material-ui/icons/People'; import PersonIcon from '@material-ui/icons/Person'; import React from 'react'; import { useApp } from '../app/AppContext'; -import { IconComponent, SystemIconKey, SystemIcons } from './types'; +import { IconComponent, SystemIconKey, IconComponentMap } from './types'; -export const defaultSystemIcons: SystemIcons = { +export const defaultSystemIcons: IconComponentMap = { user: PersonIcon, group: PeopleIcon, + dashboard: MuiDashboardIcon, + help: MuiHelpIcon, }; const overridableSystemIcon = (key: SystemIconKey): IconComponent => { @@ -35,5 +39,7 @@ const overridableSystemIcon = (key: SystemIconKey): IconComponent => { return Component; }; -export const UserIcon = overridableSystemIcon('user'); +export const DashboardIcon = overridableSystemIcon('dashboard'); export const GroupIcon = overridableSystemIcon('group'); +export const HelpIcon = overridableSystemIcon('help'); +export const UserIcon = overridableSystemIcon('user'); diff --git a/packages/core-api/src/icons/types.ts b/packages/core-api/src/icons/types.ts index 30e0ba53b2..be24b223ae 100644 --- a/packages/core-api/src/icons/types.ts +++ b/packages/core-api/src/icons/types.ts @@ -17,6 +17,8 @@ import { ComponentType } from 'react'; import { SvgIconProps } from '@material-ui/core'; +export type SystemIconKey = 'user' | 'group' | 'dashboard' | 'help'; + export type IconComponent = ComponentType; -export type SystemIconKey = 'user' | 'group'; -export type SystemIcons = { [key in SystemIconKey]: IconComponent }; +export type IconKey = SystemIconKey | string; +export type IconComponentMap = { [key in IconKey]: IconComponent }; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 60a6e43654..a5a56f633a 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/core +## 0.6.1 + +### Patch Changes + +- b51ee6ece: Fixed type inference of `createRouteRef`. + ## 0.6.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 41dbc8ef14..c9045c6465 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.6.0", + "version": "0.6.1", "private": false, "publishConfig": { "access": "public", @@ -67,7 +67,7 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/test-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 77eba9759a..28056b33bf 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -31,6 +31,7 @@ export * from './MarkdownContent'; export * from './OAuthRequestDialog'; export * from './Progress'; export * from './ProgressBars'; +export * from './Select'; export * from './SimpleStepper'; export * from './Status'; export * from './StructuredMetadataTable'; diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index 57cf435651..e41a45a225 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -215,7 +215,6 @@ export const SidebarItem = forwardRef((props, ref) => { {...childProps} activeClassName={classes.selected} to={props.to} - end ref={ref} > {content} diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 13b854972a..6a0f75ebaf 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,101 @@ # @backstage/create-app +## 0.3.9 + +### Patch Changes + +- 615103a63: Pass on plugin database management instance that is now required by the scaffolder plugin. + + To apply this change to an existing application, add the following to `src/plugins/scaffolder.ts`: + + ```diff + export default async function createPlugin({ + logger, + config, + + database, + }: PluginEnvironment) { + + // ...omitted... + + return await createRouter({ + preparers, + templaters, + publishers, + logger, + config, + dockerClient, + entityClient, + + database, + }); + } + ``` + +- 30e200d12: `@backstage/plugin-catalog-import` has been refactored, so the `App.tsx` of the backstage apps need to be updated: + + ```diff + // packages/app/src/App.tsx + + } + + element={} + /> + ``` + +- f4b576d0e: TechDocs: Add comments about migrating away from basic setup in app-config.yaml +- Updated dependencies [753bb4c40] +- Updated dependencies [1deb31141] +- Updated dependencies [6ed2b47d6] +- Updated dependencies [77ad0003a] +- Updated dependencies [6b26c9f41] +- Updated dependencies [b3f0c3811] +- Updated dependencies [d2441aee3] +- Updated dependencies [727f0deec] +- Updated dependencies [fb53eb7cb] +- Updated dependencies [07bafa248] +- Updated dependencies [ca559171b] +- Updated dependencies [ffffea8e6] +- Updated dependencies [f5e564cd6] +- Updated dependencies [f3fbfb452] +- Updated dependencies [615103a63] +- Updated dependencies [68dd79d83] +- Updated dependencies [84364b35c] +- Updated dependencies [41af18227] +- Updated dependencies [82b2c11b6] +- Updated dependencies [1df75733e] +- Updated dependencies [965e200c6] +- Updated dependencies [b51ee6ece] +- Updated dependencies [e5da858d7] +- Updated dependencies [9230d07e7] +- Updated dependencies [f5f45744e] +- Updated dependencies [0fe8ff5be] +- Updated dependencies [5a5163519] +- Updated dependencies [82b2c11b6] +- Updated dependencies [8f3443427] +- Updated dependencies [08142b256] +- Updated dependencies [08142b256] +- Updated dependencies [b51ee6ece] +- Updated dependencies [804502a5c] + - @backstage/plugin-catalog-import@0.4.0 + - @backstage/plugin-auth-backend@0.3.0 + - @backstage/plugin-catalog@0.3.1 + - @backstage/plugin-scaffolder@0.5.0 + - @backstage/plugin-scaffolder-backend@0.7.0 + - @backstage/plugin-catalog-backend@0.6.1 + - @backstage/plugin-circleci@0.2.8 + - @backstage/plugin-search@0.3.0 + - @backstage/plugin-app-backend@0.3.7 + - @backstage/backend-common@0.5.3 + - @backstage/plugin-api-docs@0.4.5 + - @backstage/plugin-lighthouse@0.2.10 + - @backstage/plugin-techdocs@0.5.6 + - @backstage/test-utils@0.1.7 + - @backstage/plugin-github-actions@0.3.2 + - @backstage/plugin-explore@0.2.5 + - @backstage/plugin-techdocs-backend@0.6.0 + - @backstage/core@0.6.1 + - @backstage/plugin-tech-radar@0.3.5 + ## 0.3.8 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 5f32a5d13d..5e4cf8ae11 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.8", + "version": "0.3.9", "private": false, "publishConfig": { "access": "public" @@ -44,31 +44,31 @@ "ts-node": "^8.6.2" }, "peerDependencies": { - "@backstage/backend-common": "^0.5.2", + "@backstage/backend-common": "^0.5.3", "@backstage/catalog-model": "^0.7.1", "@backstage/cli": "^0.6.0", "@backstage/config": "^0.1.2", - "@backstage/core": "^0.6.0", - "@backstage/plugin-api-docs": "^0.4.4", - "@backstage/plugin-app-backend": "^0.3.6", - "@backstage/plugin-auth-backend": "^0.2.12", - "@backstage/plugin-catalog": "^0.3.0", - "@backstage/plugin-catalog-backend": "^0.6.0", - "@backstage/plugin-catalog-import": "^0.3.7", - "@backstage/plugin-circleci": "^0.2.7", - "@backstage/plugin-explore": "^0.2.4", - "@backstage/plugin-github-actions": "^0.3.1", - "@backstage/plugin-lighthouse": "^0.2.9", + "@backstage/core": "^0.6.1", + "@backstage/plugin-api-docs": "^0.4.5", + "@backstage/plugin-app-backend": "^0.3.7", + "@backstage/plugin-auth-backend": "^0.3.0", + "@backstage/plugin-catalog": "^0.3.1", + "@backstage/plugin-catalog-backend": "^0.6.1", + "@backstage/plugin-catalog-import": "^0.4.0", + "@backstage/plugin-circleci": "^0.2.8", + "@backstage/plugin-explore": "^0.2.5", + "@backstage/plugin-github-actions": "^0.3.2", + "@backstage/plugin-lighthouse": "^0.2.10", "@backstage/plugin-proxy-backend": "^0.2.4", "@backstage/plugin-rollbar-backend": "^0.1.7", - "@backstage/plugin-scaffolder": "^0.4.2", - "@backstage/plugin-search": "^0.2.7", - "@backstage/plugin-scaffolder-backend": "^0.6.0", - "@backstage/plugin-tech-radar": "^0.3.4", - "@backstage/plugin-techdocs": "^0.5.5", - "@backstage/plugin-techdocs-backend": "^0.5.5", + "@backstage/plugin-scaffolder": "^0.5.0", + "@backstage/plugin-search": "^0.3.0", + "@backstage/plugin-scaffolder-backend": "^0.7.0", + "@backstage/plugin-tech-radar": "^0.3.5", + "@backstage/plugin-techdocs": "^0.5.6", + "@backstage/plugin-techdocs-backend": "^0.6.0", "@backstage/plugin-user-settings": "^0.2.5", - "@backstage/test-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.7", "@backstage/theme": "^0.2.3" }, "nodemonConfig": { 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 495b30dd6f..f8a345cd1e 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -57,6 +57,9 @@ proxy: changeOrigin: true # Reference documentation http://backstage.io/docs/features/techdocs/configuration +# Note: After experimenting with basic setup, use CI/CD to generate docs +# and an external cloud storage when deploying TechDocs for production use-case. +# https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach techdocs: builder: 'local' # Alternatives - 'external' generators: diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 6deedcd748..711e501224 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -4,7 +4,6 @@ import { AlertDisplay, OAuthRequestDialog, SidebarPage, - createRouteRef, FlatRoutes, } from '@backstage/core'; import { apis } from './apis'; @@ -13,7 +12,7 @@ import { AppSidebar } from './sidebar'; import { Route, Navigate } from 'react-router'; import { Router as CatalogRouter } from '@backstage/plugin-catalog'; import { Router as DocsRouter } from '@backstage/plugin-techdocs'; -import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import'; +import { CatalogImportPage } from '@backstage/plugin-catalog-import'; import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; import { SearchPage as SearchRouter } from '@backstage/plugin-search'; import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; @@ -29,12 +28,6 @@ const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); const deprecatedAppRoutes = app.getRoutes(); -const catalogRouteRef = createRouteRef({ - path: '/catalog', - title: 'Service Catalog', -}); - - const App = () => ( @@ -53,10 +46,7 @@ const App = () => ( path="/tech-radar" element={} /> - } - /> + } /> } diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index 251a4db7ee..4e16deebdc 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/dev-utils +## 0.1.10 + +### Patch Changes + +- Updated dependencies [b51ee6ece] +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/test-utils@0.1.7 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.1.9 ### Patch Changes diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 3a01306b46..7f53d029b3 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/dev-utils", "description": "Utilities for developing Backstage plugins.", - "version": "0.1.9", + "version": "0.1.10", "private": false, "publishConfig": { "access": "public", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", "@backstage/catalog-model": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.0.2", - "@backstage/test-utils": "^0.1.5", + "@backstage/plugin-catalog-react": "^0.0.3", + "@backstage/test-utils": "^0.1.7", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/packages/e2e-test/.eslintrc.js b/packages/e2e-test/.eslintrc.js index ce2592e03b..7c8a092081 100644 --- a/packages/e2e-test/.eslintrc.js +++ b/packages/e2e-test/.eslintrc.js @@ -1,6 +1,5 @@ module.exports = { extends: [require.resolve('@backstage/cli/config/eslint.backend')], - ignorePatterns: ['templates/**'], rules: { 'no-console': 0, 'import/no-extraneous-dependencies': [ diff --git a/packages/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index 11839d733b..7056976458 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,37 @@ # @backstage/integration +## 0.4.0 + +### Minor Changes + +- ffffea8e6: Update the `GitLabIntegrationConfig` to require the fields `apiBaseUrl` and `baseUrl`. The `readGitLabIntegrationConfig` function is now more strict and has better error reporting. This change mirrors actual reality in code more properly - the fields are actually necessary for many parts of code to actually function, so they should no longer be optional. + + Some checks that used to happen deep inside code that consumed config, now happen upfront at startup. This means that you may encounter new errors at backend startup, if you had actual mistakes in config but didn't happen to exercise the code paths that actually would break. But for most users, no change will be necessary. + + An example minimal GitLab config block that just adds a token to public GitLab would look similar to this: + + ```yaml + integrations: + gitlab: + - host: gitlab.com + token: + $env: GITLAB_TOKEN + ``` + + A full fledged config that points to a locally hosted GitLab could look like this: + + ```yaml + integrations: + gitlab: + - host: gitlab.my-company.com + apiBaseUrl: https://gitlab.my-company.com/api/v4 + baseUrl: https://gitlab.my-company.com + token: + $env: OUR_GITLAB_TOKEN + ``` + + In this case, the only optional field is `baseUrl` which is formed from the `host` if needed. + ## 0.3.2 ### Patch Changes diff --git a/packages/integration/package.json b/packages/integration/package.json index f82472ca97..b4134ab00e 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.3.2", + "version": "0.4.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -38,7 +38,7 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/test-utils": "^0.1.5", + "@backstage/test-utils": "^0.1.7", "@types/jest": "^26.0.7", "@types/luxon": "^1.25.0", "msw": "^0.21.2" diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index c20fdcef9c..dddf6ed5d0 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -19,6 +19,7 @@ import { AzureIntegration } from './azure/AzureIntegration'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegration } from './gitlab/GitLabIntegration'; +import { defaultScmResolveUrl } from './helpers'; import { ScmIntegration, ScmIntegrationRegistry, @@ -83,11 +84,11 @@ export class ScmIntegrations implements ScmIntegrationRegistry { } resolveUrl(options: { url: string; base: string }): string { - const resolve = this.byUrl(options.base)?.resolveUrl; - if (!resolve) { - return new URL(options.url, options.base).toString(); + const integration = this.byUrl(options.base); + if (!integration) { + return defaultScmResolveUrl(options); } - return resolve(options); + return integration.resolveUrl(options); } } diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts index 8a10e40bcf..3f9e165b90 100644 --- a/packages/integration/src/azure/AzureIntegration.test.ts +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -58,6 +58,16 @@ describe('AzureIntegration', () => { 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml', ); + expect( + integration.resolveUrl({ + url: '/a.yaml', + base: + 'https://dev.azure.com/organization/project/_git/repository?path=%2Ffolder%2Fcatalog-info.yaml', + }), + ).toBe( + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml', + ); + expect( integration.resolveUrl({ url: './a.yaml', diff --git a/packages/integration/src/azure/index.ts b/packages/integration/src/azure/index.ts index 6d57437779..2d95ef19b9 100644 --- a/packages/integration/src/azure/index.ts +++ b/packages/integration/src/azure/index.ts @@ -14,14 +14,15 @@ * limitations under the License. */ +export { AzureIntegration } from './AzureIntegration'; export { readAzureIntegrationConfig, readAzureIntegrationConfigs, } from './config'; export type { AzureIntegrationConfig } from './config'; export { + getAzureCommitsUrl, getAzureDownloadUrl, getAzureFileFetchUrl, getAzureRequestOptions, - getAzureCommitsUrl, } from './core'; diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts index f3e69b946a..10b69877e4 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { basicIntegrations } from '../helpers'; +import { basicIntegrations, defaultScmResolveUrl } from '../helpers'; import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { BitbucketIntegrationConfig, @@ -47,4 +47,8 @@ export class BitbucketIntegration implements ScmIntegration { get config(): BitbucketIntegrationConfig { return this.integrationConfig; } + + resolveUrl(options: { url: string; base: string }): string { + return defaultScmResolveUrl(options); + } } diff --git a/packages/integration/src/bitbucket/index.ts b/packages/integration/src/bitbucket/index.ts index 9df4d3d3fe..124fe4a2a2 100644 --- a/packages/integration/src/bitbucket/index.ts +++ b/packages/integration/src/bitbucket/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export { BitbucketIntegration } from './BitbucketIntegration'; export { readBitbucketIntegrationConfig, readBitbucketIntegrationConfigs, diff --git a/packages/integration/src/github/GitHubIntegration.ts b/packages/integration/src/github/GitHubIntegration.ts index c103597d74..c60ab462c9 100644 --- a/packages/integration/src/github/GitHubIntegration.ts +++ b/packages/integration/src/github/GitHubIntegration.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { basicIntegrations } from '../helpers'; +import { basicIntegrations, defaultScmResolveUrl } from '../helpers'; import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { GitHubIntegrationConfig, @@ -45,4 +45,8 @@ export class GitHubIntegration implements ScmIntegration { get config(): GitHubIntegrationConfig { return this.integrationConfig; } + + resolveUrl(options: { url: string; base: string }): string { + return defaultScmResolveUrl(options); + } } diff --git a/packages/integration/src/github/index.ts b/packages/integration/src/github/index.ts index 6491e8dcc5..99d2f56d0f 100644 --- a/packages/integration/src/github/index.ts +++ b/packages/integration/src/github/index.ts @@ -21,3 +21,4 @@ export { export type { GitHubIntegrationConfig } from './config'; export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core'; export { GithubCredentialsProvider } from './GithubCredentialsProvider'; +export { GitHubIntegration } from './GitHubIntegration'; diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts index 8814e33302..6faae3a5e7 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.test.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -26,6 +26,8 @@ describe('GitLabIntegration', () => { { host: 'h.com', token: 't', + apiBaseUrl: 'https://h.com/api/v4', + baseUrl: 'https://h.com', }, ], }, diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts index d939917366..131825edb5 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { basicIntegrations } from '../helpers'; +import { basicIntegrations, defaultScmResolveUrl } from '../helpers'; import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { GitLabIntegrationConfig, @@ -45,4 +45,8 @@ export class GitLabIntegration implements ScmIntegration { get config(): GitLabIntegrationConfig { return this.integrationConfig; } + + resolveUrl(options: { url: string; base: string }): string { + return defaultScmResolveUrl(options); + } } diff --git a/packages/integration/src/gitlab/config.test.ts b/packages/integration/src/gitlab/config.test.ts index 303b4bd270..bfa59d001e 100644 --- a/packages/integration/src/gitlab/config.test.ts +++ b/packages/integration/src/gitlab/config.test.ts @@ -31,6 +31,7 @@ describe('readGitLabIntegrationConfig', () => { buildConfig({ host: 'a.com', token: 't', + apiBaseUrl: 'https://a.com', baseUrl: 'https://baseurl.for.me/gitlab', }), ); @@ -38,12 +39,15 @@ describe('readGitLabIntegrationConfig', () => { expect(output).toEqual({ host: 'a.com', token: 't', + apiBaseUrl: 'https://a.com', baseUrl: 'https://baseurl.for.me/gitlab', }); }); it('inserts the defaults if missing', () => { - const output = readGitLabIntegrationConfig(buildConfig({})); + const output = readGitLabIntegrationConfig( + buildConfig({ host: 'gitlab.com' }), + ); expect(output).toEqual({ host: 'gitlab.com', apiBaseUrl: 'https://gitlab.com/api/v4', @@ -88,12 +92,15 @@ describe('readGitLabIntegrationConfigs', () => { { host: 'a.com', token: 't', + apiBaseUrl: 'https://a.com/api/v4', + baseUrl: 'https://a.com', }, ]), ); expect(output).toContainEqual({ host: 'a.com', token: 't', + apiBaseUrl: 'https://a.com/api/v4', baseUrl: 'https://a.com', }); }); @@ -104,6 +111,7 @@ describe('readGitLabIntegrationConfigs', () => { { host: 'gitlab.com', apiBaseUrl: 'https://gitlab.com/api/v4', + baseUrl: 'https://gitlab.com', }, ]); }); diff --git a/packages/integration/src/gitlab/config.ts b/packages/integration/src/gitlab/config.ts index 47269b4c32..cf717e08d2 100644 --- a/packages/integration/src/gitlab/config.ts +++ b/packages/integration/src/gitlab/config.ts @@ -15,7 +15,7 @@ */ import { Config } from '@backstage/config'; -import { isValidHost } from '../helpers'; +import { isValidHost, isValidUrl } from '../helpers'; const GITLAB_HOST = 'gitlab.com'; const GITLAB_API_BASE_URL = 'https://gitlab.com/api/v4'; @@ -38,7 +38,7 @@ export type GitLabIntegrationConfig = { * The API will always be preferred if both its base URL and a token are * present. */ - apiBaseUrl?: string; + apiBaseUrl: string; /** * The authorization token to use for requests this provider. @@ -53,7 +53,7 @@ export type GitLabIntegrationConfig = { * * If no baseUrl is provided, it will default to https://${host} */ - baseUrl?: string; + baseUrl: string; }; /** @@ -64,16 +64,10 @@ export type GitLabIntegrationConfig = { export function readGitLabIntegrationConfig( config: Config, ): GitLabIntegrationConfig { - const host = config.getOptionalString('host') ?? GITLAB_HOST; + const host = config.getString('host'); let apiBaseUrl = config.getOptionalString('apiBaseUrl'); const token = config.getOptionalString('token'); - const baseUrl = config.getOptionalString('baseUrl') ?? `https://${host}`; - - if (!isValidHost(host)) { - throw new Error( - `Invalid GitLab integration config, '${host}' is not a valid host`, - ); - } + let baseUrl = config.getOptionalString('baseUrl'); if (apiBaseUrl) { apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); @@ -81,6 +75,30 @@ export function readGitLabIntegrationConfig( apiBaseUrl = GITLAB_API_BASE_URL; } + if (baseUrl) { + baseUrl = baseUrl.replace(/\/+$/, ''); + } else { + baseUrl = `https://${host}`; + } + + if (host.includes(':')) { + throw new Error( + `Invalid GitLab integration config, host '${host}' should just be the host name (e.g. "github.com"), not a URL`, + ); + } else if (!isValidHost(host)) { + throw new Error( + `Invalid GitLab integration config, '${host}' is not a valid host`, + ); + } else if (!apiBaseUrl || !isValidUrl(apiBaseUrl)) { + throw new Error( + `Invalid GitLab integration config, '${apiBaseUrl}' is not a valid apiBaseUrl`, + ); + } else if (!isValidUrl(baseUrl)) { + throw new Error( + `Invalid GitLab integration config, '${baseUrl}' is not a valid baseUrl`, + ); + } + return { host, token, apiBaseUrl, baseUrl }; } @@ -99,7 +117,11 @@ export function readGitLabIntegrationConfigs( // As a convenience we always make sure there's at least an unauthenticated // reader for public gitlab repos. if (!result.some(c => c.host === GITLAB_HOST)) { - result.push({ host: GITLAB_HOST, apiBaseUrl: GITLAB_API_BASE_URL }); + result.push({ + host: GITLAB_HOST, + apiBaseUrl: GITLAB_API_BASE_URL, + baseUrl: `https://${GITLAB_HOST}`, + }); } return result; diff --git a/packages/integration/src/gitlab/core.test.ts b/packages/integration/src/gitlab/core.test.ts index 2b4713604c..6836854eae 100644 --- a/packages/integration/src/gitlab/core.test.ts +++ b/packages/integration/src/gitlab/core.test.ts @@ -37,10 +37,14 @@ describe('gitlab core', () => { const configWithToken: GitLabIntegrationConfig = { host: 'g.com', token: '0123456789', + apiBaseUrl: '', + baseUrl: '', }; const configWithNoToken: GitLabIntegrationConfig = { host: 'g.com', + apiBaseUrl: '', + baseUrl: '', }; describe('getGitLabFileFetchUrl', () => { diff --git a/packages/integration/src/gitlab/index.ts b/packages/integration/src/gitlab/index.ts index 8dc4e90764..8886e0e3ce 100644 --- a/packages/integration/src/gitlab/index.ts +++ b/packages/integration/src/gitlab/index.ts @@ -20,3 +20,4 @@ export { } from './config'; export type { GitLabIntegrationConfig } from './config'; export { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core'; +export { GitLabIntegration } from './GitLabIntegration'; diff --git a/packages/integration/src/helpers.test.ts b/packages/integration/src/helpers.test.ts index 9f4c531ba6..2899f2ed98 100644 --- a/packages/integration/src/helpers.test.ts +++ b/packages/integration/src/helpers.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { isValidHost } from './helpers'; +import { defaultScmResolveUrl, isValidHost } from './helpers'; describe('isValidHost', () => { it.each([ @@ -51,3 +51,69 @@ describe('isValidHost', () => { expect(isValidHost(str)).toBe(expected); }); }); + +describe('defaultScmResolveUrl', () => { + it('works for relative paths and retains query params', () => { + expect( + defaultScmResolveUrl({ + url: './b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml', + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/b.yaml', + ); + + expect( + defaultScmResolveUrl({ + url: './b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml?at=master', + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/b.yaml?at=master', + ); + + expect( + defaultScmResolveUrl({ + url: 'b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml', + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/b.yaml', + ); + }); + + it('works for absolute paths and retains query params', () => { + expect( + defaultScmResolveUrl({ + url: '/other/b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml', + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/other/b.yaml', + ); + + expect( + defaultScmResolveUrl({ + url: '/other/b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml?at=master', + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/other/b.yaml?at=master', + ); + }); + + it('works for full urls and throws away query params', () => { + expect( + defaultScmResolveUrl({ + url: 'https://b.com/b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml?at=master', + }), + ).toBe('https://b.com/b.yaml'); + }); +}); diff --git a/packages/integration/src/helpers.ts b/packages/integration/src/helpers.ts index cc1c59a238..c09f808011 100644 --- a/packages/integration/src/helpers.ts +++ b/packages/integration/src/helpers.ts @@ -14,13 +14,25 @@ * limitations under the License. */ +import parseGitUrl from 'git-url-parse'; import { ScmIntegration, ScmIntegrationsGroup } from './types'; -/** Checks whether the given url is a valid host */ -export function isValidHost(url: string): boolean { +/** Checks whether the given argument is a valid URL hostname */ +export function isValidHost(host: string): boolean { const check = new URL('http://example.com'); - check.host = url; - return check.host === url; + check.host = host; + return check.host === host; +} + +/** Checks whether the given argument is a valid URL */ +export function isValidUrl(url: string): boolean { + try { + // eslint-disable-next-line no-new + new URL(url); + return true; + } catch { + return false; + } } export function basicIntegrations( @@ -40,3 +52,43 @@ export function basicIntegrations( }, }; } + +/** + * Default implementation of ScmIntegration.resolveUrl, that only works with + * URL pathname based providers. + */ +export function defaultScmResolveUrl(options: { + url: string; + base: string; +}): string { + const { url, base } = options; + + // If it is a fully qualified URL - then return it verbatim + try { + // eslint-disable-next-line no-new + new URL(url); + return url; + } catch { + // ignore intentionally + } + + let updated: URL; + + if (url.startsWith('/')) { + // If it is an absolute path, move relative to the repo root + const { filepath } = parseGitUrl(base); + updated = new URL(base); + const repoRootPath = updated.pathname + .substring(0, updated.pathname.length - filepath.length) + .replace(/\/+$/, ''); + updated.pathname = `${repoRootPath}${url}`; + } else { + // For relative URLs, just let the default URL constructor handle the + // resolving. Note that this essentially will treat the last segment of the + // base as a file - NOT a folder - unless the url ends in a slash. + updated = new URL(url, base); + } + + updated.search = new URL(base).search; + return updated.toString(); +} diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index fdcd7da676..c39608fe8e 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -18,5 +18,6 @@ export * from './azure'; export * from './bitbucket'; export * from './github'; export * from './gitlab'; +export { defaultScmResolveUrl } from './helpers'; export { ScmIntegrations } from './ScmIntegrations'; export type { ScmIntegration, ScmIntegrationRegistry } from './types'; diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index 7d5b0fbe74..c63a323dc3 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -36,16 +36,21 @@ export interface ScmIntegration { title: string; /** - * Works like the two-argument form of the URL constructor, resolving an - * absolute or relative URL in relation to a base URL. + * Resolves an absolute or relative URL in relation to a base URL. * - * If this method is not implemented, the URL constructor is used instead for - * URLs that match this integration. + * This method is adapted for use within SCM systems, so relative URLs are + * within the context of the root of the hierarchy pointed to by the base + * URL. + * + * For example, if the base URL is `/folder/a.yaml`, i.e. + * within the file tree of a certain repo, an absolute path of `/b.yaml` does + * not resolve to `https://hostname/b.yaml` but rather to + * `/b.yaml` inside the file tree of that same repo. * * @param options.url The (absolute or relative) URL or path to resolve * @param options.base The base URL onto which this resolution happens */ - resolveUrl?(options: { url: string; base: string }): string; + resolveUrl(options: { url: string; base: string }): string; } /** @@ -83,8 +88,16 @@ export interface ScmIntegrationRegistry gitlab: ScmIntegrationsGroup; /** - * Works like the two-argument form of the URL constructor, resolving an - * absolute or relative URL in relation to a base URL. + * Resolves an absolute or relative URL in relation to a base URL. + * + * This method is adapted for use within SCM systems, so relative URLs are + * within the context of the root of the hierarchy pointed to by the base + * URL. + * + * For example, if the base URL is `/folder/a.yaml`, i.e. + * within the file tree of a certain repo, an absolute path of `/b.yaml` does + * not resolve to `https://hostname/b.yaml` but rather to + * `/b.yaml` inside the file tree of that same repo. * * @param options.url The (absolute or relative) URL or path to resolve * @param options.base The base URL onto which this resolution happens diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index baf7a380a2..9f2a8c0be4 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/techdocs-common +## 0.4.0 + +### Minor Changes + +- 08142b256: URL Preparer will now use proper etag based caching introduced in https://github.com/backstage/backstage/pull/4120. Previously, builds used to be cached for 30 minutes. + +### Patch Changes + +- 77ad0003a: Revert AWS SDK version to v2 +- 08142b256: TechDocs will throw warning in backend logs when legacy git preparer or dir preparer is used to preparer docs. Migrate to URL Preparer by updating `backstage.io/techdocs-ref` annotation to be prefixed with `url:`. + Detailed docs are here https://backstage.io/docs/features/techdocs/how-to-guides#how-to-use-url-reader-in-techdocs-prepare-step + See benefits and reason for doing so https://github.com/backstage/backstage/issues/4409 +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [5a5163519] + - @backstage/backend-common@0.5.3 + - @backstage/integration@0.4.0 + ## 0.3.7 ### Patch Changes diff --git a/packages/techdocs-common/__mocks__/@aws-sdk/client-s3.ts b/packages/techdocs-common/__mocks__/aws-sdk.ts similarity index 52% rename from packages/techdocs-common/__mocks__/@aws-sdk/client-s3.ts rename to packages/techdocs-common/__mocks__/aws-sdk.ts index 2d2581a6cd..987b3d2800 100644 --- a/packages/techdocs-common/__mocks__/@aws-sdk/client-s3.ts +++ b/packages/techdocs-common/__mocks__/aws-sdk.ts @@ -13,49 +13,74 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { S3ClientConfig } from '@aws-sdk/client-s3'; +import type { S3 as S3Types } from 'aws-sdk'; import { EventEmitter } from 'events'; import fs from 'fs'; export class S3 { private readonly options; - constructor(options: S3ClientConfig) { + constructor(options: S3Types.ClientConfiguration) { this.options = options; } headObject({ Key }: { Key: string }) { + return { + promise: () => this.checkFileExists(Key), + }; + } + + getObject({ Key }: { Key: string }) { + return { + promise: () => this.checkFileExists(Key), + createReadStream: () => { + const emitter = new EventEmitter(); + process.nextTick(() => { + if (fs.existsSync(Key)) { + emitter.emit('data', Buffer.from(fs.readFileSync(Key))); + } else { + emitter.emit( + 'error', + new Error(`The file ${Key} doest not exist !`), + ); + } + emitter.emit('end'); + }); + return emitter; + }, + }; + } + + checkFileExists(Key: string) { return new Promise((resolve, reject) => { if (fs.existsSync(Key)) { resolve(''); } else { - reject({ message: `The file ${Key} doest not exist.` }); - } - }); - } - - getObject({ Key }: { Key: string }) { - return new Promise((resolve, reject) => { - if (fs.existsSync(Key)) { - const emitter = new EventEmitter(); - process.nextTick(() => { - emitter.emit('data', Buffer.from(fs.readFileSync(Key))); - emitter.emit('end'); - }); - resolve({ - Body: emitter, - }); - } else { - reject({ message: `The file ${Key} doest not exist.` }); + reject({ message: 'The object doest not exist !' }); } }); } headBucket() { - return ''; + return new Promise(resolve => { + resolve(''); + }); } - putObject() { - return ''; + upload({ Key }: { Key: string }) { + return { + promise: () => + new Promise((resolve, reject) => { + if (!fs.existsSync(Key)) { + reject(''); + } else { + resolve(''); + } + }), + }; } } + +export default { + S3, +}; diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 9b0f7fbf9f..f1236cd6d5 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.3.7", + "version": "0.4.0", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -36,16 +36,16 @@ "url": "https://github.com/backstage/backstage/issues" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1.0", "@azure/identity": "^1.2.2", "@azure/storage-blob": "^12.4.0", - "@backstage/backend-common": "^0.5.2", + "@backstage/backend-common": "^0.5.3", "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.3.2", + "@backstage/integration": "^0.4.0", "@google-cloud/storage": "^5.6.0", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", + "aws-sdk": "^2.840.0", "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", "express": "^4.17.1", @@ -60,7 +60,6 @@ "winston": "^3.2.1" }, "devDependencies": { - "@aws-sdk/types": "3.1.0", "@backstage/cli": "^0.6.0", "@types/fs-extra": "^9.0.5", "@types/git-url-parse": "^9.0.0", diff --git a/packages/techdocs-common/src/helpers.test.ts b/packages/techdocs-common/src/helpers.test.ts index eba1386b11..6520f0f8e4 100644 --- a/packages/techdocs-common/src/helpers.test.ts +++ b/packages/techdocs-common/src/helpers.test.ts @@ -139,7 +139,7 @@ describe('getDocFilesFromRepository', () => { archive: async () => { return Readable.from(''); }, - etag: '', + etag: 'etag123abc', }; } @@ -156,6 +156,7 @@ describe('getDocFilesFromRepository', () => { mockEntityWithAnnotation, ); - expect(output).toBe('/tmp/testfolder'); + expect(output.preparedDir).toBe('/tmp/testfolder'); + expect(output.etag).toBe('etag123abc'); }); }); diff --git a/packages/techdocs-common/src/helpers.ts b/packages/techdocs-common/src/helpers.ts index f70b0f3840..beff0b2a6e 100644 --- a/packages/techdocs-common/src/helpers.ts +++ b/packages/techdocs-common/src/helpers.ts @@ -14,17 +14,17 @@ * limitations under the License. */ -import os from 'os'; -import path from 'path'; -import parseGitUrl from 'git-url-parse'; -import fs from 'fs-extra'; -import { InputError, UrlReader, Git } from '@backstage/backend-common'; +import { Git, InputError, UrlReader } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; +import fs from 'fs-extra'; +import parseGitUrl from 'git-url-parse'; +import os from 'os'; +import path from 'path'; +import { Logger } from 'winston'; import { getDefaultBranch } from './default-branch'; import { getGitRepoType, getTokenForGitRepo } from './git-auth'; -import { RemoteProtocol } from './stages/prepare/types'; -import { Logger } from 'winston'; +import { PreparerResponse, RemoteProtocol } from './stages/prepare/types'; export type ParsedLocationAnnotation = { type: RemoteProtocol; @@ -196,16 +196,9 @@ export const checkoutGitRepository = async ( }; export const getLastCommitTimestamp = async ( - repositoryUrl: string, - config: Config, + repositoryLocation: string, logger: Logger, ): Promise => { - const repositoryLocation = await checkoutGitRepository( - repositoryUrl, - config, - logger, - ); - const git = Git.fromAuth({ logger }); const sha = await git.resolveRef({ dir: repositoryLocation, ref: 'HEAD' }); const commit = await git.readCommit({ dir: repositoryLocation, sha }); @@ -216,13 +209,22 @@ export const getLastCommitTimestamp = async ( export const getDocFilesFromRepository = async ( reader: UrlReader, entity: Entity, -): Promise => { + opts?: { etag?: string; logger?: Logger }, +): Promise => { const { target } = parseReferenceAnnotation( 'backstage.io/techdocs-ref', entity, ); - const response = await reader.readTree(target); + opts?.logger?.info(`Reading files from ${target}`); + // readTree will throw NotModifiedError if etag has not changed. + const readTreeResponse = await reader.readTree(target, { etag: opts?.etag }); + const preparedDir = await readTreeResponse.dir(); - return await response.dir(); + opts?.logger?.info(`Tree downloaded and stored at ${preparedDir}`); + + return { + preparedDir, + etag: readTreeResponse.etag, + }; }; diff --git a/packages/techdocs-common/src/stages/prepare/commonGit.test.ts b/packages/techdocs-common/src/stages/prepare/commonGit.test.ts index 704cd23daa..dcc06313bd 100644 --- a/packages/techdocs-common/src/stages/prepare/commonGit.test.ts +++ b/packages/techdocs-common/src/stages/prepare/commonGit.test.ts @@ -16,8 +16,8 @@ import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; -import { CommonGitPreparer } from './commonGit'; import { checkoutGitRepository } from '../../helpers'; +import { CommonGitPreparer } from './commonGit'; function normalizePath(path: string) { return path @@ -29,6 +29,7 @@ function normalizePath(path: string) { jest.mock('../../helpers', () => ({ ...jest.requireActual<{}>('../../helpers'), checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch'), + getLastCommitTimestamp: jest.fn(() => 12345678), })); const createMockEntity = (annotations = {}) => { @@ -57,9 +58,9 @@ describe('commonGit preparer', () => { 'github:https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend/examples/documented-component', }); - const tempDocsPath = await preparer.prepare(mockEntity); + const { preparedDir } = await preparer.prepare(mockEntity); expect(checkoutGitRepository).toHaveBeenCalledTimes(1); - expect(normalizePath(tempDocsPath)).toEqual( + expect(normalizePath(preparedDir)).toEqual( '/tmp/backstage-repo/org/name/branch/plugins/techdocs-backend/examples/documented-component', ); }); @@ -72,9 +73,9 @@ describe('commonGit preparer', () => { 'gitlab:https://gitlab.com/xesjkeee/go-logger/blob/master/catalog-info.yaml', }); - const tempDocsPath = await preparer.prepare(mockEntity); + const { preparedDir } = await preparer.prepare(mockEntity); expect(checkoutGitRepository).toHaveBeenCalledTimes(2); - expect(normalizePath(tempDocsPath)).toEqual( + expect(normalizePath(preparedDir)).toEqual( '/tmp/backstage-repo/org/name/branch/catalog-info.yaml', ); }); @@ -87,9 +88,9 @@ describe('commonGit preparer', () => { 'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', }); - const tempDocsPath = await preparer.prepare(mockEntity); + const { preparedDir } = await preparer.prepare(mockEntity); expect(checkoutGitRepository).toHaveBeenCalledTimes(3); - expect(normalizePath(tempDocsPath)).toEqual( + expect(normalizePath(preparedDir)).toEqual( '/tmp/backstage-repo/org/name/branch/template.yaml', ); }); diff --git a/packages/techdocs-common/src/stages/prepare/commonGit.ts b/packages/techdocs-common/src/stages/prepare/commonGit.ts index 7eac07d76f..46b96675f3 100644 --- a/packages/techdocs-common/src/stages/prepare/commonGit.ts +++ b/packages/techdocs-common/src/stages/prepare/commonGit.ts @@ -13,14 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import path from 'path'; -import parseGitUrl from 'git-url-parse'; +import { NotModifiedError } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { PreparerBase } from './types'; -import { parseReferenceAnnotation, checkoutGitRepository } from '../../helpers'; - +import parseGitUrl from 'git-url-parse'; +import path from 'path'; import { Logger } from 'winston'; +import { + checkoutGitRepository, + getLastCommitTimestamp, + parseReferenceAnnotation, +} from '../../helpers'; +import { PreparerBase, PreparerResponse } from './types'; export class CommonGitPreparer implements PreparerBase { private readonly config: Config; @@ -31,23 +35,45 @@ export class CommonGitPreparer implements PreparerBase { this.logger = logger; } - async prepare(entity: Entity): Promise { + async prepare( + entity: Entity, + options?: { etag?: string }, + ): Promise { + this.logger.warn( + 'You are using the legacy git preparer in TechDocs which will be removed in near future (March 2021). ' + + 'Migrate to URL reader by updating `backstage.io/techdocs-ref` annotation in `catalog-info.yaml` ' + + 'to be prefixed with `url:`. Read the migration guide and benefits at https://github.com/backstage/backstage/issues/4409 ', + ); + const { target } = parseReferenceAnnotation( 'backstage.io/techdocs-ref', entity, ); try { + // Update repository or do a fresh clone. const repoPath = await checkoutGitRepository( target, this.config, this.logger, ); - const parsedGitLocation = parseGitUrl(target); + // Check if etag has changed for cache invalidation. + const etag = await getLastCommitTimestamp(repoPath, this.logger); + if (options?.etag === etag.toString()) { + throw new NotModifiedError(); + } - return path.join(repoPath, parsedGitLocation.filepath); + const parsedGitLocation = parseGitUrl(target); + return { + preparedDir: path.join(repoPath, parsedGitLocation.filepath), + etag: etag.toString(), + }; } catch (error) { - this.logger.debug(`Repo checkout failed with error ${error.message}`); + if (error instanceof NotModifiedError) { + this.logger.debug(`Cache is valid for etag ${options?.etag}`); + } else { + this.logger.debug(`Repo checkout failed with error ${error.message}`); + } throw error; } } diff --git a/packages/techdocs-common/src/stages/prepare/dir.test.ts b/packages/techdocs-common/src/stages/prepare/dir.test.ts index cd8d358afe..67fa0dd21f 100644 --- a/packages/techdocs-common/src/stages/prepare/dir.test.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.test.ts @@ -15,8 +15,8 @@ */ import { getVoidLogger, UrlReader } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; -import { DirectoryPreparer } from './dir'; import { checkoutGitRepository } from '../../helpers'; +import { DirectoryPreparer } from './dir'; function normalizePath(path: string) { return path @@ -28,6 +28,7 @@ function normalizePath(path: string) { jest.mock('../../helpers', () => ({ ...jest.requireActual<{}>('../../helpers'), checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch/'), + getLastCommitTimestamp: jest.fn(() => 12345678), })); const logger = getVoidLogger(); @@ -66,9 +67,8 @@ describe('directory preparer', () => { 'backstage.io/techdocs-ref': 'dir:./our-documentation', }); - expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual( - '/directory/our-documentation', - ); + const { preparedDir } = await directoryPreparer.prepare(mockEntity); + expect(normalizePath(preparedDir)).toEqual('/directory/our-documentation'); }); it('should merge managed-by-location and techdocs-ref when techdocs-ref is absolute', async () => { @@ -84,9 +84,8 @@ describe('directory preparer', () => { 'backstage.io/techdocs-ref': 'dir:/our-documentation/techdocs', }); - expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual( - '/our-documentation/techdocs', - ); + const { preparedDir } = await directoryPreparer.prepare(mockEntity); + expect(normalizePath(preparedDir)).toEqual('/our-documentation/techdocs'); }); it('should merge managed-by-location and techdocs-ref when managed-by-location is a git repository', async () => { @@ -102,7 +101,8 @@ describe('directory preparer', () => { 'backstage.io/techdocs-ref': 'dir:./docs', }); - expect(normalizePath(await directoryPreparer.prepare(mockEntity))).toEqual( + const { preparedDir } = await directoryPreparer.prepare(mockEntity); + expect(normalizePath(preparedDir)).toEqual( '/tmp/backstage-repo/org/name/branch/docs', ); expect(checkoutGitRepository).toHaveBeenCalledTimes(1); diff --git a/packages/techdocs-common/src/stages/prepare/dir.ts b/packages/techdocs-common/src/stages/prepare/dir.ts index ab277fda9a..08832938df 100644 --- a/packages/techdocs-common/src/stages/prepare/dir.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.ts @@ -13,14 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PreparerBase } from './types'; +import { + InputError, + NotModifiedError, + UrlReader, +} from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import path from 'path'; -import { parseReferenceAnnotation, checkoutGitRepository } from '../../helpers'; -import { UrlReader, InputError } from '@backstage/backend-common'; import parseGitUrl from 'git-url-parse'; +import path from 'path'; import { Logger } from 'winston'; +import { + checkoutGitRepository, + getLastCommitTimestamp, + parseReferenceAnnotation, +} from '../../helpers'; +import { PreparerBase, PreparerResponse } from './types'; export class DirectoryPreparer implements PreparerBase { constructor( @@ -33,7 +41,10 @@ export class DirectoryPreparer implements PreparerBase { this.reader = reader; } - private async resolveManagedByLocationToDir(entity: Entity) { + private async resolveManagedByLocationToDir( + entity: Entity, + options?: { etag?: string }, + ): Promise { const { type, target } = parseReferenceAnnotation( 'backstage.io/managed-by-location', entity, @@ -44,8 +55,14 @@ export class DirectoryPreparer implements PreparerBase { ); switch (type) { case 'url': { - const response = await this.reader.readTree(target); - return await response.dir(); + const response = await this.reader.readTree(target, { + etag: options?.etag, + }); + const preparedDir = await response.dir(); + return { + preparedDir, + etag: response.etag, + }; } case 'github': case 'gitlab': @@ -57,29 +74,47 @@ export class DirectoryPreparer implements PreparerBase { this.logger, ); - return path.dirname( - path.join(repoLocation, parsedGitLocation.filepath), - ); + // Check if etag has changed for cache invalidation. + const etag = await getLastCommitTimestamp(repoLocation, this.logger); + if (options?.etag === etag.toString()) { + throw new NotModifiedError(); + } + return { + preparedDir: path.dirname( + path.join(repoLocation, parsedGitLocation.filepath), + ), + etag: etag.toString(), + }; } case 'file': - return path.dirname(target); + return { + preparedDir: path.dirname(target), + // Instead of supporting caching on local sources, use techdocs-cli for local development and debugging. + etag: '', + }; default: throw new InputError(`Unable to resolve location type ${type}`); } } - async prepare(entity: Entity): Promise { + async prepare(entity: Entity): Promise { + this.logger.warn( + 'You are using the legacy dir preparer in TechDocs which will be removed in near future (March 2021). ' + + 'Migrate to URL reader by updating `backstage.io/techdocs-ref` annotation in `catalog-info.yaml` ' + + 'to be prefixed with `url:`. Read the migration guide and benefits at https://github.com/backstage/backstage/issues/4409 ', + ); + const { target } = parseReferenceAnnotation( 'backstage.io/techdocs-ref', entity, ); - const managedByLocationDirectory = await this.resolveManagedByLocationToDir( - entity, - ); + // This will throw NotModified error if etag has not changed. + const response = await this.resolveManagedByLocationToDir(entity); - return new Promise(resolve => { - resolve(path.resolve(managedByLocationDirectory, target)); - }); + return { + preparedDir: path.resolve(response.preparedDir, target), + etag: response.etag, + }; } } diff --git a/packages/techdocs-common/src/stages/prepare/types.ts b/packages/techdocs-common/src/stages/prepare/types.ts index 97e75306c3..96510e8eec 100644 --- a/packages/techdocs-common/src/stages/prepare/types.ts +++ b/packages/techdocs-common/src/stages/prepare/types.ts @@ -16,13 +16,31 @@ import type { Entity } from '@backstage/catalog-model'; import { Logger } from 'winston'; +export type PreparerResponse = { + /** + * The path to directory where the tree is downloaded. + */ + preparedDir: string; + /** + * A unique identifer of the tree blob, usually the commit SHA or etag from the target. + */ + etag: string; +}; + export type PreparerBase = { /** * Given an Entity definition from the Service Catalog, go and prepare a directory - * with contents from the location in temporary storage and return the path + * with contents from the location in temporary storage and return the path. + * * @param entity The entity from the Service Catalog + * @param options.etag (Optional) If etag is provider, it will be used to check if the target has + * updated since the last build. + * @throws {NotModifiedError} when the prepared directory has not been changed since the last build. */ - prepare(entity: Entity, opts?: { logger: Logger }): Promise; + prepare( + entity: Entity, + options?: { logger?: Logger; etag?: string }, + ): Promise; }; export type PreparerBuilder = { @@ -30,10 +48,14 @@ export type PreparerBuilder = { get(entity: Entity): PreparerBase; }; +/** + * Everything except `url` will be deprecated. + * Read more https://github.com/backstage/backstage/issues/4409 + */ export type RemoteProtocol = + | 'url' | 'dir' | 'github' | 'gitlab' | 'file' - | 'azure/api' - | 'url'; + | 'azure/api'; diff --git a/packages/techdocs-common/src/stages/prepare/url.ts b/packages/techdocs-common/src/stages/prepare/url.ts index b938c6b5bc..9e4715a4ee 100644 --- a/packages/techdocs-common/src/stages/prepare/url.ts +++ b/packages/techdocs-common/src/stages/prepare/url.ts @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Logger } from 'winston'; +import { NotModifiedError, UrlReader } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; -import { UrlReader } from '@backstage/backend-common'; -import { PreparerBase } from './types'; +import { Logger } from 'winston'; import { getDocFilesFromRepository } from '../../helpers'; +import { PreparerBase, PreparerResponse } from './types'; export class UrlPreparer implements PreparerBase { private readonly logger: Logger; @@ -28,13 +28,25 @@ export class UrlPreparer implements PreparerBase { this.reader = reader; } - async prepare(entity: Entity): Promise { + async prepare( + entity: Entity, + options?: { etag?: string }, + ): Promise { try { - return getDocFilesFromRepository(this.reader, entity); + return await getDocFilesFromRepository(this.reader, entity, { + etag: options?.etag, + logger: this.logger, + }); } catch (error) { - this.logger.debug( - `Unable to fetch files for building docs ${error.message}`, - ); + // NotModifiedError means that etag based cache is still valid. + if (error instanceof NotModifiedError) { + this.logger.debug(`Cache is valid for etag ${options?.etag}`); + } else { + this.logger.debug( + `Unable to fetch files for building docs ${error.message}`, + ); + } + throw error; } } diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts old mode 100755 new mode 100644 index 3902aab21a..813f6b8554 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -102,6 +102,12 @@ describe('AwsS3Publish', () => { }); it('should fail to publish a directory', async () => { + const wrongPathToGeneratedDirectory = path.join( + 'wrong', + 'path', + 'to', + 'generatedDirectory', + ); const entity = createMockEntity(); const entityRootDir = getEntityRootDir(entity); @@ -118,11 +124,13 @@ describe('AwsS3Publish', () => { await publisher .publish({ entity, - directory: '/wrong/path/to/generatedDirectory', + directory: wrongPathToGeneratedDirectory, }) .catch(error => - expect(error.message).toContain( - 'Unable to upload file(s) to AWS S3. Error Failed to read template directory', + expect(error).toEqual( + new Error( + `Unable to upload file(s) to AWS S3. Error Failed to read template directory: ENOENT, no such file or directory '${wrongPathToGeneratedDirectory}'`, + ), ), ); mockFs.restore(); @@ -198,17 +206,14 @@ describe('AwsS3Publish', () => { it('should return an error if the techdocs_metadata.json file is not present', async () => { const entityNameMock = createMockEntityName(); const entity = createMockEntity(); - const { - metadata: { name, namespace }, - kind, - } = entity; + const entityRootDir = getEntityRootDir(entity); await publisher .fetchTechDocsMetadata(entityNameMock) .catch(error => expect(error).toEqual( new Error( - `TechDocs metadata fetch failed, The file ${namespace}/${kind}/${name}/techdocs_metadata.json doest not exist.`, + `TechDocs metadata fetch failed, The file ${entityRootDir}/techdocs_metadata.json doest not exist !`, ), ), ); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 20e6bc424d..8eaf03eb2f 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -15,7 +15,8 @@ */ import path from 'path'; import express from 'express'; -import { PutObjectCommandOutput, S3 } from '@aws-sdk/client-s3'; +import aws from 'aws-sdk'; +import { ManagedUpload } from 'aws-sdk/clients/s3'; import { Logger } from 'winston'; import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; @@ -71,7 +72,7 @@ export class AwsS3Publish implements PublisherBase { // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html const region = config.getOptionalString('techdocs.publisher.awsS3.region'); - const storageClient = new S3({ + const storageClient = new aws.S3({ ...(credentials && accessKeyId && secretAccessKey && { @@ -113,7 +114,7 @@ export class AwsS3Publish implements PublisherBase { } constructor( - private readonly storageClient: S3, + private readonly storageClient: aws.S3, private readonly bucketName: string, private readonly logger: Logger, ) { @@ -133,7 +134,7 @@ export class AwsS3Publish implements PublisherBase { const allFilesToUpload = await getFileTreeRecursively(directory); const limiter = createLimiter(10); - const uploadPromises: Array> = []; + const uploadPromises: Array> = []; for (const filePath of allFilesToUpload) { // Remove the absolute path prefix of the source directory // Path of all files to upload, relative to the root of the source directory @@ -151,7 +152,9 @@ export class AwsS3Publish implements PublisherBase { }; // Rate limit the concurrent execution of file uploads to batches of 10 (per publish) - const uploadFile = limiter(() => this.storageClient.putObject(params)); + const uploadFile = limiter(() => + this.storageClient.upload(params).promise(), + ); uploadPromises.push(uploadFile); } await Promise.all(uploadPromises); @@ -170,34 +173,33 @@ export class AwsS3Publish implements PublisherBase { entityName: EntityName, ): Promise { try { - return await new Promise((resolve, reject) => { + return await new Promise(async (resolve, reject) => { const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; - this.storageClient + const stream = this.storageClient .getObject({ Bucket: this.bucketName, Key: `${entityRootDir}/techdocs_metadata.json`, }) - .then(async file => { - const techdocsMetadataJson = await streamToBuffer( - file.Body as Readable, - ); + .createReadStream(); - if (!techdocsMetadataJson) { - throw new Error( - `Unable to parse the techdocs metadata file ${entityRootDir}/techdocs_metadata.json.`, - ); - } - const techdocsMetadata = JSON5.parse( - techdocsMetadataJson.toString('utf-8'), + try { + const techdocsMetadataJson = await streamToBuffer(stream); + if (!techdocsMetadataJson) { + throw new Error( + `Unable to parse the techdocs metadata file ${entityRootDir}/techdocs_metadata.json.`, ); + } - resolve(techdocsMetadata); - }) - .catch(err => { - this.logger.error(err.message); - reject(new Error(err.message)); - }); + const techdocsMetadata = JSON5.parse( + techdocsMetadataJson.toString('utf-8'), + ); + + resolve(techdocsMetadata); + } catch (err) { + this.logger.error(err.message); + reject(new Error(err.message)); + } }); } catch (e) { throw new Error(`TechDocs metadata fetch failed, ${e.message}`); @@ -208,7 +210,7 @@ export class AwsS3Publish implements PublisherBase { * Express route middleware to serve static files on a route in techdocs-backend. */ docsRouter(): express.Handler { - return (req, res) => { + return async (req, res) => { // Trim the leading forward slash // filePath example - /default/Component/documented-component/index.html const filePath = req.path.replace(/^\//, ''); @@ -217,27 +219,22 @@ export class AwsS3Publish implements PublisherBase { const fileExtension = path.extname(filePath); const responseHeaders = getHeadersForFileExtension(fileExtension); - this.storageClient + const stream = this.storageClient .getObject({ Bucket: this.bucketName, Key: filePath }) - .then(async object => { - const fileContent = await streamToBuffer(object.Body as Readable); - if (!fileContent) { - throw new Error(`Unable to parse the file ${filePath}.`); - } + .createReadStream(); + try { + // Inject response headers + for (const [headerKey, headerValue] of Object.entries( + responseHeaders, + )) { + res.setHeader(headerKey, headerValue); + } - // Inject response headers - for (const [headerKey, headerValue] of Object.entries( - responseHeaders, - )) { - res.setHeader(headerKey, headerValue); - } - - res.send(fileContent); - }) - .catch(err => { - this.logger.warn(err.message); - res.status(404).send(err.message); - }); + res.send(await streamToBuffer(stream)); + } catch (err) { + this.logger.warn(err.message); + res.status(404).send(err.message); + } }; } @@ -248,10 +245,12 @@ export class AwsS3Publish implements PublisherBase { async hasDocsBeenGenerated(entity: Entity): Promise { try { const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; - await this.storageClient.headObject({ - Bucket: this.bucketName, - Key: `${entityRootDir}/index.html`, - }); + await this.storageClient + .headObject({ + Bucket: this.bucketName, + Key: `${entityRootDir}/index.html`, + }) + .promise(); return Promise.resolve(true); } catch (e) { return Promise.resolve(false); diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index e349a2119a..ac09331dcd 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -88,7 +88,7 @@ export class LocalPublish implements PublisherBase { ); reject(err); } - + this.logger.info(`Published site stored at ${publishDir}`); this.discovery .getBaseUrl('techdocs') .then(techdocsApiUrl => { diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index 6d23f26691..a30b2b3f6a 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/test-utils +## 0.1.7 + +### Patch Changes + +- b51ee6ece: Added `mountedRoutes` option to `wrapInTestApp`, allowing routes to be associated to concrete paths to make `useRouteRef` usable in tested components. + ## 0.1.6 ### Patch Changes diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 5cea83fa03..5a4ad2b4a4 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.6", + "version": "0.1.7", "private": false, "publishConfig": { "access": "public", diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index 4735beb150..47379a9b18 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-api-docs +## 0.4.5 + +### Patch Changes + +- f5e564cd6: Improve display of error messages +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.4.4 ### Patch Changes diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 175b831ada..8df169c1d9 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.4.4", + "version": "0.4.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ "dependencies": { "@asyncapi/react-component": "^0.18.2", "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", - "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", @@ -50,8 +50,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index b57e0d25fb..f39dad2eb9 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-app-backend +## 0.3.7 + +### Patch Changes + +- 727f0deec: Added a new `disableConfigInjection` option, which can be used to disable the configuration injection in environments where it can't be used. +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [5a5163519] + - @backstage/backend-common@0.5.3 + ## 0.3.6 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index cca36a51f8..52ebab520d 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-backend", - "version": "0.3.6", + "version": "0.3.7", "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.5.2", + "@backstage/backend-common": "^0.5.3", "@backstage/config-loader": "^0.5.1", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index e3ff6aaa43..e97d79c4ae 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,77 @@ # @backstage/plugin-auth-backend +## 0.3.0 + +### Minor Changes + +- 1deb31141: Remove undocumented scope (default) from the OIDC auth provider which was breaking some identity services. If your app relied on this scope, you can manually specify it by adding a new factory in `packages/app/src/apis.ts`: + + ``` + export const apis = [ + createApiFactory({ + api: oidcAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider: { + id: 'oidc', + title: 'Your Identity Provider', + icon: OAuth2Icon, + }, + defaultScopes: [ + 'default', + 'openid', + 'email', + 'offline_access', + ], + environment: configApi.getOptionalString('auth.environment'), + }), + }), + ]; + ``` + +### Patch Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- 07bafa248: Add configurable `scope` for oauth2 auth provider. + + Some OAuth2 providers require certain scopes to facilitate a user sign-in using the Authorization Code flow. + This change adds the optional `scope` key to auth.providers.oauth2. An example is: + + ```yaml + auth: + providers: + oauth2: + development: + clientId: + $env: DEV_OAUTH2_CLIENT_ID + clientSecret: + $env: DEV_OAUTH2_CLIENT_SECRET + authorizationUrl: + $env: DEV_OAUTH2_AUTH_URL + tokenUrl: + $env: DEV_OAUTH2_TOKEN_URL + scope: saml-login-selector openid profile email + ``` + + This tells the OAuth 2.0 AS to perform a SAML login and return OIDC information include the `profile` + and `email` claims as part of the ID Token. + +- Updated dependencies [6ed2b47d6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [72b96e880] +- Updated dependencies [5a5163519] + - @backstage/catalog-client@0.3.6 + - @backstage/backend-common@0.5.3 + ## 0.2.12 ### Patch Changes diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 84f71b52b9..9850e05235 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -58,7 +58,13 @@ export interface Config { development: { [key: string]: string }; }; oauth2?: { - development: { [key: string]: string }; + development: { + clientId: string; + clientSecret: string; + authorizationUrl: string; + tokenUrl: string; + scope?: string; + }; }; oidc?: { development: { [key: string]: string }; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index e87c2b4a90..c50789d4c1 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.2.12", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.2", - "@backstage/catalog-client": "^0.3.5", + "@backstage/backend-common": "^0.5.3", + "@backstage/catalog-client": "^0.3.6", "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", @@ -47,7 +47,7 @@ "jose": "^1.27.1", "jwt-decode": "^3.1.0", "knex": "^0.21.6", - "moment": "^2.26.0", + "luxon": "^1.25.0", "morgan": "^1.10.0", "node-cache": "^5.1.2", "openid-client": "^4.2.1", diff --git a/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts b/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts index b22ab0ecda..8896a0d158 100644 --- a/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts +++ b/plugins/auth-backend/src/identity/DatabaseKeyStore.test.ts @@ -15,8 +15,8 @@ */ import Knex from 'knex'; -import moment from 'moment'; import { DatabaseKeyStore } from './DatabaseKeyStore'; +import { DateTime } from 'luxon'; function createDB() { const knex = Knex({ @@ -51,7 +51,11 @@ describe('DatabaseKeyStore', () => { const { items } = await store.listKeys(); expect(items).toEqual([{ createdAt: expect.anything(), key }]); - expect(Math.abs(items[0].createdAt.diff(moment(), 's'))).toBeLessThan(10); + expect( + Math.abs( + DateTime.fromJSDate(items[0].createdAt).diffNow('seconds').seconds, + ), + ).toBeLessThan(10); }); it('should remove stored keys', async () => { diff --git a/plugins/auth-backend/src/identity/DatabaseKeyStore.ts b/plugins/auth-backend/src/identity/DatabaseKeyStore.ts index c24cac55be..e53102e333 100644 --- a/plugins/auth-backend/src/identity/DatabaseKeyStore.ts +++ b/plugins/auth-backend/src/identity/DatabaseKeyStore.ts @@ -15,9 +15,9 @@ */ import Knex from 'knex'; -import { utc } from 'moment'; import { resolvePackagePath } from '@backstage/backend-common'; import { AnyJWK, KeyStore, StoredKey } from './types'; +import { DateTime } from 'luxon'; const migrationsDir = resolvePackagePath( '@backstage/plugin-auth-backend', @@ -27,7 +27,7 @@ const migrationsDir = resolvePackagePath( const TABLE = 'signing_keys'; type Row = { - created_at: Date; + created_at: Date; // row.created_at is a string after being returned from the database kid: string; key: string; }; @@ -36,6 +36,21 @@ type Options = { database: Knex; }; +const parseDate = (date: string | Date) => { + const parsedDate = + typeof date === 'string' + ? DateTime.fromSQL(date, { locale: 'UTC' }) + : DateTime.fromJSDate(date); + + if (!parsedDate.isValid) { + throw new Error( + `Failed to parse date, reason: ${parsedDate.invalidReason}, explanation: ${parsedDate.invalidExplanation}`, + ); + } + + return parsedDate.toJSDate(); +}; + export class DatabaseKeyStore implements KeyStore { static async create(options: Options): Promise { const { database } = options; @@ -66,7 +81,7 @@ export class DatabaseKeyStore implements KeyStore { return { items: rows.map(row => ({ key: JSON.parse(row.key), - createdAt: utc(row.created_at), + createdAt: parseDate(row.created_at), })), }; } diff --git a/plugins/auth-backend/src/identity/MemoryKeyStore.ts b/plugins/auth-backend/src/identity/MemoryKeyStore.ts index b35002b146..3dc1d777c9 100644 --- a/plugins/auth-backend/src/identity/MemoryKeyStore.ts +++ b/plugins/auth-backend/src/identity/MemoryKeyStore.ts @@ -14,18 +14,15 @@ * limitations under the License. */ -import { utc } from 'moment'; import { KeyStore, AnyJWK, StoredKey } from './types'; +import { DateTime } from 'luxon'; export class MemoryKeyStore implements KeyStore { - private readonly keys = new Map< - string, - { createdAt: moment.Moment; key: string } - >(); + private readonly keys = new Map(); async addKey(key: AnyJWK): Promise { this.keys.set(key.kid, { - createdAt: utc(), + createdAt: DateTime.utc().toJSDate(), key: JSON.stringify(key), }); } diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 36622332e0..1c6192d5c5 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import moment from 'moment'; import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types'; import { JSONWebKey, JWK, JWS } from 'jose'; import { Logger } from 'winston'; import { v4 as uuid } from 'uuid'; +import { DateTime } from 'luxon'; const MS_IN_S = 1000; @@ -52,7 +52,7 @@ export class TokenFactory implements TokenIssuer { private readonly keyStore: KeyStore; private readonly keyDurationSeconds: number; - private keyExpiry?: moment.Moment; + private keyExpiry?: Date; private privateKeyPromise?: Promise; constructor(options: Options) { @@ -90,8 +90,10 @@ export class TokenFactory implements TokenIssuer { for (const key of keys) { // Allow for a grace period of another full key duration before we remove the keys from the database - const expireAt = key.createdAt.add(3 * this.keyDurationSeconds, 's'); - if (expireAt.isBefore()) { + const expireAt = DateTime.fromJSDate(key.createdAt).plus({ + seconds: 3 * this.keyDurationSeconds, + }); + if (expireAt < DateTime.local()) { expiredKeys.push(key); } else { validKeys.push(key); @@ -117,14 +119,21 @@ export class TokenFactory implements TokenIssuer { private async getKey(): Promise { // Make sure that we only generate one key at a time if (this.privateKeyPromise) { - if (this.keyExpiry?.isAfter()) { + if ( + this.keyExpiry && + DateTime.fromJSDate(this.keyExpiry) > DateTime.local() + ) { return this.privateKeyPromise; } this.logger.info(`Signing key has expired, generating new key`); delete this.privateKeyPromise; } - this.keyExpiry = moment().add(this.keyDurationSeconds, 'seconds'); + this.keyExpiry = DateTime.utc() + .plus({ + seconds: this.keyDurationSeconds, + }) + .toJSDate(); const promise = (async () => { // This generates a new signing key to be used to sign tokens until the next key rotation const key = await JWK.generate('EC', 'P-256', { diff --git a/plugins/auth-backend/src/identity/types.ts b/plugins/auth-backend/src/identity/types.ts index 827aba51ab..6e984d41cc 100644 --- a/plugins/auth-backend/src/identity/types.ts +++ b/plugins/auth-backend/src/identity/types.ts @@ -52,7 +52,7 @@ export type TokenIssuer = { */ export type StoredKey = { key: AnyJWK; - createdAt: moment.Moment; + createdAt: Date; }; /** diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index ec6aa1b6ba..4d4bcaf2bf 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -38,11 +38,14 @@ describe('CatalogIdentityClient', () => { client.findUser({ annotations: { key: 'value' } }); - expect(catalogApi.getEntities).toBeCalledWith({ - filter: { - kind: 'user', - 'metadata.annotations.key': 'value', + expect(catalogApi.getEntities).toBeCalledWith( + { + filter: { + kind: 'user', + 'metadata.annotations.key': 'value', + }, }, - }); + undefined, + ); }); }); diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts index 5bc4e5de21..e1a3553394 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -37,7 +37,10 @@ export class CatalogIdentityClient { * * Throws a NotFoundError or ConflictError if 0 or multiple users are found. */ - async findUser(query: UserQuery): Promise { + async findUser( + query: UserQuery, + options?: { token?: string }, + ): Promise { const filter: Record = { kind: 'user', }; @@ -45,7 +48,7 @@ export class CatalogIdentityClient { filter[`metadata.annotations.${key}`] = value; } - const { items } = await this.catalogApi.getEntities({ filter }); + const { items } = await this.catalogApi.getEntities({ filter }, options); if (items.length !== 1) { if (items.length > 1) { diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index ae4f5fabd0..848a02bad6 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -38,6 +38,7 @@ import { PassportDoneCallback, } from '../../lib/passport'; import { AuthProviderFactory, RedirectInfo } from '../types'; +import { TokenIssuer } from '../../identity'; type PrivateInfo = { refreshToken: string; @@ -46,16 +47,19 @@ type PrivateInfo = { export type GoogleAuthProviderOptions = OAuthProviderOptions & { logger: Logger; identityClient: CatalogIdentityClient; + tokenIssuer: TokenIssuer; }; export class GoogleAuthProvider implements OAuthHandlers { private readonly _strategy: GoogleStrategy; private readonly logger: Logger; private readonly identityClient: CatalogIdentityClient; + private readonly tokenIssuer: TokenIssuer; constructor(options: GoogleAuthProviderOptions) { this.logger = options.logger; this.identityClient = options.identityClient; + this.tokenIssuer = options.tokenIssuer; // TODO: throw error if env variables not set? this._strategy = new GoogleStrategy( { @@ -150,11 +154,17 @@ export class GoogleAuthProvider implements OAuthHandlers { } try { - const user = await this.identityClient.findUser({ - annotations: { - 'google.com/email': profile.email, - }, + const token = await this.tokenIssuer.issueToken({ + claims: { sub: 'backstage.io/auth-backend' }, }); + const user = await this.identityClient.findUser( + { + annotations: { + 'google.com/email': profile.email, + }, + }, + { token }, + ); return { ...response, @@ -192,6 +202,7 @@ export const createGoogleProvider: AuthProviderFactory = ({ clientSecret, callbackUrl, logger, + tokenIssuer, identityClient: new CatalogIdentityClient({ catalogApi }), }); diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts index 04cb7670f3..a44c4fb50b 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -44,6 +44,7 @@ type PrivateInfo = { export type OAuth2AuthProviderOptions = OAuthProviderOptions & { authorizationUrl: string; tokenUrl: string; + scope?: string; }; export class OAuth2AuthProvider implements OAuthHandlers { @@ -58,6 +59,7 @@ export class OAuth2AuthProvider implements OAuthHandlers { authorizationURL: options.authorizationUrl, tokenURL: options.tokenUrl, passReqToCallback: false as true, + scope: options.scope, }, ( accessToken: any, @@ -168,6 +170,7 @@ export const createOAuth2Provider: AuthProviderFactory = ({ const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; const authorizationUrl = envConfig.getString('authorizationUrl'); const tokenUrl = envConfig.getString('tokenUrl'); + const scope = envConfig.getOptionalString('scope'); const provider = new OAuth2AuthProvider({ clientId, @@ -175,6 +178,7 @@ export const createOAuth2Provider: AuthProviderFactory = ({ callbackUrl, authorizationUrl, tokenUrl, + scope, }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index 9e44a82c37..6ea7b8a8d3 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -65,7 +65,7 @@ export class OidcAuthProvider implements OAuthHandlers { return await executeRedirectStrategy(req, strategy, { accessType: 'offline', prompt: 'none', - scope: `${req.scope} default`, + scope: req.scope, state: encodeState(req.state), }); } diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index eb256d2c08..9ae261ecf3 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,34 @@ # @backstage/plugin-catalog-backend +## 0.6.1 + +### Patch Changes + +- 77ad0003a: Revert AWS SDK version to v2 +- d2441aee3: use child logger, if provided, to log single location refresh +- fb53eb7cb: Don't respond to a request twice if an entity has not been found. +- f3fbfb452: add indices on columns referring locations(id) +- 84364b35c: Added an option to scan GitHub for repositories using a new location type `github-discovery`. + Example: + + ```yaml + type: 'github-discovery', + target: + 'https://github.com/backstage/techdocs-*/blob/master/catalog.yaml' + ``` + + You can use wildcards (`*`) as well. This will add `location` entities for each matching repository. + Currently though, you must specify the exact path of the `catalog.yaml` file in the repository. + +- 82b2c11b6: Refactored route response handling to use more explicit types and throw errors. +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [5a5163519] + - @backstage/backend-common@0.5.3 + - @backstage/integration@0.4.0 + ## 0.6.0 ### Minor Changes diff --git a/plugins/catalog-backend/migrations/20210209121210_locations_fk_index.js b/plugins/catalog-backend/migrations/20210209121210_locations_fk_index.js new file mode 100644 index 0000000000..5591717c13 --- /dev/null +++ b/plugins/catalog-backend/migrations/20210209121210_locations_fk_index.js @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.index('location_id', 'entity_location_id_idx'); + }); + await knex.schema.alterTable('location_update_log', table => { + table.index('location_id', 'update_log_location_id_idx'); + }); + } +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('entities', table => { + table.dropIndex([], 'entity_location_id_idx'); + }); + await knex.schema.alterTable('location_update_log', table => { + table.dropIndex([], 'update_log_location_id_idx'); + }); + } +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index bb76441f3f..254898a086 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.6.0", + "version": "0.6.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,15 +29,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@aws-sdk/client-organizations": "^3.2.0", "@azure/msal-node": "^1.0.0-beta.3", - "@backstage/backend-common": "^0.5.2", + "@backstage/backend-common": "^0.5.3", "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.3.2", + "@backstage/integration": "^0.4.0", "@octokit/graphql": "^4.5.8", "@types/express": "^4.17.6", "@types/ldapjs": "^1.0.9", + "aws-sdk": "^2.840.0", "codeowners-utils": "^1.0.2", "core-js": "^3.6.5", "cross-fetch": "^3.0.6", @@ -59,7 +59,7 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/test-utils": "^0.1.6", + "@backstage/test-utils": "^0.1.7", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", "@types/lodash": "^4.14.151", diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts index 28200021cc..ba163fa0c3 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts @@ -27,18 +27,22 @@ describe('AwsOrganizationCloudAccountProcessor', () => { afterEach(() => jest.resetAllMocks()); it('generates component entities for accounts', async () => { - listAccounts.mockImplementation(() => - Promise.resolve({ - Accounts: [ - { - Arn: - 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', - Name: 'testaccount', - }, - ], - NextToken: undefined, - }), - ); + listAccounts.mockImplementation(() => { + return { + async promise() { + return { + Accounts: [ + { + Arn: + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + Name: 'testaccount', + }, + ], + NextToken: undefined, + }; + }, + }; + }); await processor.readLocation(location, false, emit); expect(emit).toBeCalledWith({ type: 'entity', @@ -69,23 +73,27 @@ describe('AwsOrganizationCloudAccountProcessor', () => { type: 'aws-cloud-accounts', target: 'o-1vl18kc5a3', }; - listAccounts.mockImplementation(() => - Promise.resolve({ - Accounts: [ - { - Arn: - 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', - Name: 'testaccount', - }, - { - Arn: - 'arn:aws:organizations::192594491037:account/o-zzzzzzzzz/957140518395', - Name: 'testaccount2', - }, - ], - NextToken: undefined, - }), - ); + listAccounts.mockImplementation(() => { + return { + async promise() { + return { + Accounts: [ + { + Arn: + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + Name: 'testaccount', + }, + { + Arn: + 'arn:aws:organizations::192594491037:account/o-zzzzzzzzz/957140518395', + Name: 'testaccount2', + }, + ], + NextToken: undefined, + }; + }, + }; + }); await processor.readLocation(locationTest, false, emit); expect(emit).toBeCalledTimes(1); expect(emit).toBeCalledWith({ diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts index 40516f88f7..0e988e7307 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts @@ -14,11 +14,8 @@ * limitations under the License. */ import { LocationSpec, ResourceEntityV1alpha1 } from '@backstage/catalog-model'; -import { - Account, - Organizations, - ListAccountsCommandOutput, -} from '@aws-sdk/client-organizations'; +import AWS, { Organizations } from 'aws-sdk'; +import { Account, ListAccountsResponse } from 'aws-sdk/clients/organizations'; import * as results from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; @@ -38,7 +35,7 @@ const ORGANIZATION_ANNOTATION: string = 'amazonaws.com/organization-id'; export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { organizations: Organizations; constructor() { - this.organizations = new Organizations({ + this.organizations = new AWS.Organizations({ region: AWS_ORGANIZATION_REGION, }); // Only available in us-east-1 } @@ -67,9 +64,9 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { let nextToken = undefined; while (isInitialAttempt || nextToken) { isInitialAttempt = false; - const orgAccounts: ListAccountsCommandOutput = await this.organizations.listAccounts( - { NextToken: nextToken }, - ); + const orgAccounts: ListAccountsResponse = await this.organizations + .listAccounts({ NextToken: nextToken }) + .promise(); if (orgAccounts.Accounts) { awsAccounts = awsAccounts.concat(orgAccounts.Accounts); } diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts new file mode 100644 index 0000000000..41698e1d04 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import { LocationSpec } from '@backstage/catalog-model'; +import { GithubDiscoveryProcessor, parseUrl } from './GithubDiscoveryProcessor'; +import { getOrganizationRepositories } from './github'; +import { ConfigReader } from '@backstage/config'; + +jest.mock('./github'); +const mockGetOrganizationRepositories = getOrganizationRepositories as jest.MockedFunction< + typeof getOrganizationRepositories +>; + +describe('GithubDiscoveryProcessor', () => { + describe('parseUrl', () => { + it('parses well formed URLs', () => { + expect( + parseUrl('https://github.com/foo/proj/blob/master/catalog.yaml'), + ).toEqual({ + org: 'foo', + repoSearchPath: /^proj$/, + catalogPath: '/blob/master/catalog.yaml', + }); + expect( + parseUrl('https://github.com/foo/proj*/blob/master/catalog.yaml'), + ).toEqual({ + org: 'foo', + repoSearchPath: /^proj.*$/, + catalogPath: '/blob/master/catalog.yaml', + }); + }); + + it('throws on incorrectly formed URLs', () => { + expect(() => parseUrl('https://github.com')).toThrow(); + expect(() => parseUrl('https://github.com//')).toThrow(); + expect(() => parseUrl('https://github.com/foo')).toThrow(); + expect(() => parseUrl('https://github.com//foo')).toThrow(); + expect(() => parseUrl('https://github.com/org/teams')).toThrow(); + expect(() => parseUrl('https://github.com/org//teams')).toThrow(); + }); + }); + + describe('reject unrelated entries', () => { + it('rejects unknown types', async () => { + const processor = GithubDiscoveryProcessor.fromConfig( + new ConfigReader({ + integrations: { + github: [{ host: 'github.com', token: 'blob' }], + }, + }), + { logger: getVoidLogger() }, + ); + const location: LocationSpec = { + type: 'not-github-discovery', + target: 'https://github.com', + }; + await expect( + processor.readLocation(location, false, () => {}), + ).resolves.toBeFalsy(); + }); + + it('rejects unknown targets', async () => { + const processor = GithubDiscoveryProcessor.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'blob' }, + { host: 'ghe.example.net', token: 'blob' }, + ], + }, + }), + { logger: getVoidLogger() }, + ); + const location: LocationSpec = { + type: 'github-discovery', + target: 'https://not.github.com/apa', + }; + await expect( + processor.readLocation(location, false, () => {}), + ).rejects.toThrow( + /There is no GitHub integration that matches https:\/\/not.github.com\/apa/, + ); + }); + }); + + describe('handles repositories', () => { + const processor = GithubDiscoveryProcessor.fromConfig( + new ConfigReader({ + integrations: { + github: [{ host: 'github.com', token: 'blob' }], + }, + }), + { logger: getVoidLogger() }, + ); + + beforeEach(() => { + mockGetOrganizationRepositories.mockClear(); + }); + + it('output all repositories', async () => { + const location: LocationSpec = { + type: 'github-discovery', + target: 'https://github.com/backstage/*/blob/master/catalog.yaml', + }; + mockGetOrganizationRepositories.mockResolvedValueOnce({ + repositories: [ + { name: 'backstage', url: 'https://github.com/backstage/backstage' }, + { name: 'demo', url: 'https://github.com/backstage/demo' }, + ], + }); + const emitter = jest.fn(); + + await processor.readLocation(location, false, emitter); + + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: + 'https://github.com/backstage/backstage/blob/master/catalog.yaml', + }, + optional: false, + }); + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: 'https://github.com/backstage/demo/blob/master/catalog.yaml', + }, + optional: false, + }); + }); + + it('output repositories with wildcards', async () => { + const location: LocationSpec = { + type: 'github-discovery', + target: + 'https://github.com/backstage/techdocs-*/blob/master/catalog.yaml', + }; + mockGetOrganizationRepositories.mockResolvedValueOnce({ + repositories: [ + { name: 'backstage', url: 'https://github.com/backstage/backstage' }, + { + name: 'techdocs-cli', + url: 'https://github.com/backstage/techdocs-cli', + }, + { + name: 'techdocs-container', + url: 'https://github.com/backstage/techdocs-container', + }, + ], + }); + const emitter = jest.fn(); + + await processor.readLocation(location, false, emitter); + + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: + 'https://github.com/backstage/techdocs-cli/blob/master/catalog.yaml', + }, + optional: false, + }); + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: + 'https://github.com/backstage/techdocs-container/blob/master/catalog.yaml', + }, + optional: false, + }); + }); + it('filter unrelated repositories', async () => { + const location: LocationSpec = { + type: 'github-discovery', + target: 'https://github.com/backstage/test/blob/master/catalog.yaml', + }; + mockGetOrganizationRepositories.mockResolvedValueOnce({ + repositories: [ + { name: 'abstest', url: 'https://github.com/backstage/abctest' }, + { + name: 'test', + url: 'https://github.com/backstage/test', + }, + { + name: 'testxyz', + url: 'https://github.com/backstage/testxyz', + }, + ], + }); + const emitter = jest.fn(); + + await processor.readLocation(location, false, emitter); + + expect(emitter).toHaveBeenCalledWith({ + type: 'location', + location: { + type: 'url', + target: 'https://github.com/backstage/test/blob/master/catalog.yaml', + }, + optional: false, + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts new file mode 100644 index 0000000000..9af247fd25 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts @@ -0,0 +1,130 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { + GithubCredentialsProvider, + ScmIntegrations, +} from '@backstage/integration'; +import { graphql } from '@octokit/graphql'; +import { Logger } from 'winston'; +import { getOrganizationRepositories } from './github'; +import * as results from './results'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; + +/** + * Extracts repositories out of a GitHub org. + */ +export class GithubDiscoveryProcessor implements CatalogProcessor { + private readonly integrations: ScmIntegrations; + private readonly logger: Logger; + + static fromConfig(config: Config, options: { logger: Logger }) { + const integrations = ScmIntegrations.fromConfig(config); + + return new GithubDiscoveryProcessor({ + ...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 !== 'github-discovery') { + return false; + } + + const gitHubConfig = this.integrations.github.byUrl(location.target) + ?.config; + if (!gitHubConfig) { + throw new Error( + `There is no GitHub integration that matches ${location.target}. Please add a configuration entry for it under integrations.github`, + ); + } + const { headers } = await GithubCredentialsProvider.create( + gitHubConfig, + ).getCredentials({ url: location.target }); + const { org, repoSearchPath, catalogPath } = parseUrl(location.target); + + const client = graphql.defaults({ + baseUrl: gitHubConfig.apiBaseUrl, + headers, + }); + + // Read out all of the raw data + const startTimestamp = Date.now(); + this.logger.info(`Reading GitHub repositories from ${location.target}`); + + const { repositories } = await getOrganizationRepositories(client, org); + + const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1); + this.logger.debug( + `Read ${repositories.length} GitHub repositories in ${duration} seconds`, + ); + + for (const repository of repositories) { + if (!repoSearchPath.test(repository.name)) { + continue; + } + emit( + results.location( + { + type: 'url', + target: `${repository.url}${catalogPath}`, + }, + false, + ), + ); + } + + return true; + } +} + +/* + * Helpers + */ + +export function parseUrl( + urlString: string, +): { org: string; repoSearchPath: RegExp; catalogPath: string } { + const url = new URL(urlString); + const path = url.pathname.substr(1).split('/'); + + // /backstage/techdocs-*/blob/master/catalog-info.yaml + if (path.length > 2 && path[0].length && path[1].length) { + return { + org: decodeURIComponent(path[0]), + repoSearchPath: escapeRegExp(decodeURIComponent(path[1])), + catalogPath: `/${decodeURIComponent(path.slice(2).join('/'))}`, + }; + } + + throw new Error(`Failed to parse ${urlString}`); +} + +export function escapeRegExp(str: string): RegExp { + return new RegExp(`^${str.replace(/\*/g, '.*')}$`); +} diff --git a/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts index 8911e84d99..81b44c706d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts @@ -22,6 +22,7 @@ import { getOrganizationTeams, getOrganizationUsers, getTeamMembers, + getOrganizationRepositories, QueryResponse, } from './github'; @@ -151,4 +152,48 @@ describe('github', () => { await expect(getTeamMembers(graphql, 'a', 'b')).resolves.toEqual(output); }); }); + + describe('getOrganizationRepositories', () => { + it('read repositories', async () => { + const input: QueryResponse = { + organization: { + repositories: { + nodes: [ + { + name: 'backstage', + url: 'https://github.com/backstage/backstage', + }, + { + name: 'demo', + url: 'https://github.com/backstage/demo', + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + }, + }; + + const output = { + repositories: [ + { name: 'backstage', url: 'https://github.com/backstage/backstage' }, + { + name: 'demo', + url: 'https://github.com/backstage/demo', + }, + ], + }; + + server.use( + graphqlMsw.query('repositories', (_req, res, ctx) => + res(ctx.data(input)), + ), + ); + + await expect(getOrganizationRepositories(graphql, 'a')).resolves.toEqual( + output, + ); + }); + }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/github/github.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.ts index 2b33d72f65..d50887c592 100644 --- a/plugins/catalog-backend/src/ingestion/processors/github/github.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.ts @@ -27,6 +27,7 @@ export type Organization = { membersWithRole?: Connection; team?: Team; teams?: Connection; + repositories?: Connection; }; export type PageInfo = { @@ -52,6 +53,11 @@ export type Team = { members: Connection; }; +export type Repository = { + name: string; + url: string; +}; + export type Connection = { pageInfo: PageInfo; nodes: T[]; @@ -216,6 +222,39 @@ export async function getOrganizationTeams( return { groups, groupMemberUsers }; } +export async function getOrganizationRepositories( + client: typeof graphql, + org: string, +): Promise<{ repositories: Repository[] }> { + const query = ` + query repositories($org: String!, $cursor: String) { + organization(login: $org) { + name + repositories(first: 100, after: $cursor) { + nodes { + name + url + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `; + + const repositories = await queryWithPaging( + client, + query, + r => r.organization?.repositories, + x => x, + { org }, + ); + + return { repositories }; +} + /** * Gets all the users out of a GitHub organization. * diff --git a/plugins/catalog-backend/src/ingestion/processors/github/index.ts b/plugins/catalog-backend/src/ingestion/processors/github/index.ts index e424d7bafb..2063e8c1b2 100644 --- a/plugins/catalog-backend/src/ingestion/processors/github/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/index.ts @@ -16,4 +16,8 @@ export { readGithubConfig } from './config'; export type { ProviderConfig } from './config'; -export { getOrganizationTeams, getOrganizationUsers } from './github'; +export { + getOrganizationTeams, + getOrganizationUsers, + getOrganizationRepositories, +} from './github'; diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index 05dd327f0e..736c64ea39 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -21,6 +21,7 @@ export { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAcco export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; export { CodeOwnersProcessor } from './CodeOwnersProcessor'; export { FileReaderProcessor } from './FileReaderProcessor'; +export { GithubDiscoveryProcessor } from './GithubDiscoveryProcessor'; export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor'; export { LdapOrgReaderProcessor } from './LdapOrgReaderProcessor'; export { LocationEntityProcessor } from './LocationEntityProcessor'; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index f7544ef42e..a75c7da6e8 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -43,6 +43,7 @@ import { CatalogProcessorParser, CodeOwnersProcessor, FileReaderProcessor, + GithubDiscoveryProcessor, GithubOrgReaderProcessor, HigherOrderOperation, HigherOrderOperations, @@ -303,6 +304,7 @@ export class CatalogBuilder { if (!this.processorsReplace) { processors.push( new FileReaderProcessor(), + GithubDiscoveryProcessor.fromConfig(config, { logger }), GithubOrgReaderProcessor.fromConfig(config, { logger }), LdapOrgReaderProcessor.fromConfig(config, { logger }), MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }), diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 0311d865a9..3966557555 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,45 @@ # @backstage/plugin-catalog-import +## 0.4.0 + +### Minor Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- 68dd79d83: The plugin has been refactored and is now based on a configurable state machine of 'analyze', 'prepare', 'review' & 'finish'. + Depending on the outcome of the 'analyze' stage, different flows are selected ('single-location', 'multiple-locations', 'no-location'). + Each flow can define it's own components that guide the user. + + During the refactoring, the `catalogRouteRef` property of the `CatalogImportPage` has been removed, so the `App.tsx` of the backstage apps need to be updated: + + ```diff + // packages/app/src/App.tsx + + } + + element={} + /> + ``` + +### Patch Changes + +- 753bb4c40: Flatten the options of the `CatalogImportPage` from the `options` property to the `pullRequest` property. + + ```diff + - + + + ``` + +- Updated dependencies [6ed2b47d6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [72b96e880] +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/catalog-client@0.3.6 + - @backstage/integration@0.4.0 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.3.7 ### Patch Changes diff --git a/plugins/catalog-import/README.md b/plugins/catalog-import/README.md index 1dc0d7cf89..97371ce0e7 100644 --- a/plugins/catalog-import/README.md +++ b/plugins/catalog-import/README.md @@ -1,21 +1,84 @@ -# Catalog import plugin +# Catalog Import -Welcome to the catalog-import plugin! +The Catalog Import Plugin provides a wizard to onboard projects with existing `catalog-info.yaml` files. +It also assists by creating pull requests in repositories where no `catalog-info.yaml` exists. -This plugin allows you to bootstrap a component-config YAML file for your repository and open a pull request to add it. +![Catalog Import Plugin](./docs/catalog-import-screenshot.png) -When installed it is accessible on [localhost:3000/catalog-import](localhost:3000/catalog-import). +Current features: - +- Import `catalog-info.yaml` files from a URL in a repository of one of the supported Git integrations (example `https://github.com/backstage/backstage/catalog-info.yaml`). +- _[GitHub only]_ Search for all `catalog-info.yaml` files in a Git repository (example: `https://github.com/backstage/backstage`). +- _[GitHub only]_ Analyze a repository, generate a Component entity, and create a Pull Request to onboard the repository. -## Running +Some features are not yet available for all supported Git providers. -Just run the backstage. +## Getting Started -``` -yarn start && yarn --cwd packages/backend start +1. Install the Catalog Import Plugin: + +```bash +# packages/app + +yarn add @backstage/plugin-catalog-import ``` -## Usage +2. Add the plugin to the app: -Pretty straightforward, navigate to [localhost:3000/catalog-import](localhost:3000/catalog-import) and enter your repo's URL. +```ts +// packages/app/src/plugins.ts + +export { catalogImportPlugin } from '@backstage/plugin-catalog-import'; +``` + +3. Register the `CatalogImportPage` at the `/catalog-import` path: + +```tsx +// packages/app/src/App.tsx + +import { CatalogImportPage } from '@backstage/plugin-catalog-import'; + +} />; +``` + +## Customizations + +### Disable the creation of Pull Requests + +The pull request feature can be disabled by options that are passed to the `CatalogImportPage`: + +```tsx +// packages/app/src/App.tsx + +} +/> +``` + +### Customize the title and body of the Pull Request + +The pull request form is filled with a default title and body. +This can be configured by options that are passed to the `CatalogImportPage`: + +```tsx +// packages/app/src/App.tsx + + ({ + title: 'My title', + body: 'My **markdown** body', + }), + }} + /> + } +/> +``` + +## Development + +Use `yarn start` to run a [development version](./dev/index.tsx) of the plugin that can be used to validate each flow with mocked data. diff --git a/plugins/catalog-import/dev/index.tsx b/plugins/catalog-import/dev/index.tsx index c9b46a7ed4..e794628e6c 100644 --- a/plugins/catalog-import/dev/index.tsx +++ b/plugins/catalog-import/dev/index.tsx @@ -14,7 +14,338 @@ * limitations under the License. */ +import { CatalogApi } from '@backstage/catalog-client'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Content, Header, InfoCard, Page } from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; -import { catalogImportPlugin } from '../src/plugin'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Grid, ListItem, ListItemIcon, ListItemText } from '@material-ui/core'; +import AlarmIcon from '@material-ui/icons/Alarm'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; +import React from 'react'; +import { + AnalyzeResult, + CatalogImportApi, + catalogImportApiRef, + EntityListComponent, + ImportStepper, +} from '../src'; +import { ImportComponentPage } from '../src/components/ImportComponentPage'; -createDevApp().registerPlugin(catalogImportPlugin).render(); +const getEntityNames = (url: string): EntityName[] => [ + { + kind: 'Component', + namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default', + name: 'component-a', + }, + { + kind: 'API', + namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default', + name: 'api-a', + }, +]; + +const getEntities = (url: string): Entity[] => [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default', + name: 'component-a', + }, + }, + { + apiVersion: '1', + kind: 'API', + metadata: { + namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default', + name: 'api-a', + }, + }, +]; + +const locations = [ + { + target: 'https://my-location-1', + entities: [ + { + kind: 'Domain', + namespace: 'default', + name: 'my-domain', + }, + { + kind: 'Group', + namespace: 'groups', + name: 'my-group', + }, + { + kind: 'Location', + namespace: 'default', + name: 'my-location', + }, + { + kind: 'System', + namespace: 'default', + name: 'my-system', + }, + { + kind: 'User', + namespace: 'users', + name: 'my-api', + }, + ], + }, + { + target: 'https://my-location-2', + entities: [ + { + kind: 'API', + namespace: 'default', + name: 'my-api', + }, + { + kind: 'Component', + namespace: 'default', + name: 'my-component', + }, + { + kind: 'Location', + namespace: 'default', + name: 'my-location', + }, + ], + }, +]; + +createDevApp() + .registerApi({ + api: catalogApiRef, + deps: {}, + factory: () => + ({ + getEntities: async () => { + await new Promise(r => setTimeout(r, 1000)); + + return { + items: [ + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'group-a', + namespace: 'default', + }, + spec: { + profile: { + displayName: 'Group A', + }, + }, + }, + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'group-b', + namespace: 'default', + }, + spec: { + profile: { + displayName: 'Group B', + }, + }, + }, + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'group-a', + namespace: 'other', + }, + spec: { + profile: { + displayName: 'Group A', + }, + }, + }, + ] as Entity[], + }; + }, + + addLocation: async location => { + await new Promise(r => setTimeout(r, 1000)); + + return { + location, + entities: getEntities(location.target), + }; + }, + } as CatalogApi), + }) + .registerApi({ + api: catalogImportApiRef, + deps: {}, + factory: () => + ({ + analyzeUrl: async (url: string): Promise => { + await new Promise(r => setTimeout(r, 500)); + + switch (url) { + case 'https://0': + return { + type: 'repository', + url, + integrationType: 'github', + generatedEntities: getEntities(url), + }; + + case 'https://1': + case 'https://2/catalog-info.yaml': + case 'https://2/folder-a/catalog-info.yaml': + case 'https://2/folder-b/catalog-info.yaml': + return { + type: 'locations', + locations: [ + { + target: url.includes('/catalog-info.yaml') + ? url + : `${url}/catalog-info.yaml`, + entities: getEntityNames(url), + }, + ], + }; + + case 'https://2': { + const urls = [ + `${url}/catalog-info.yaml`, + `${url}/folder-a/catalog-info.yaml`, + `${url}/folder-b/catalog-info.yaml`, + ]; + + return { + type: 'locations', + locations: urls.map(u => ({ + target: u, + entities: getEntityNames(u), + })), + }; + } + + default: + throw new Error(`Invalid url ${url}`); + } + }, + + submitPullRequest: async ({ + repositoryUrl, + }): Promise<{ + link: string; + location: string; + }> => { + await new Promise(r => setTimeout(r, 2500)); + + return { + link: `${repositoryUrl}/pulls/1`, + location: `${repositoryUrl}/blob/catalog-info.yaml`, + }; + }, + } as CatalogImportApi), + }) + .addPage({ + title: 'Catalog Import', + element: , + }) + .addPage({ + title: 'Catalog Import 2', + element: ( + +
+ + + + + + + + + + + + + + + + + + + + + ), + }) + .addPage({ + title: 'Components', + element: ( + +
+ + + + + } + /> + + + + + + + + + + + } + locations={locations} + locationListItemIcon={() => } + onItemClick={() => {}} + /> + + + + + } + /> + + + + + } + withLinks + /> + + + + + + ), + }) + .render(); diff --git a/plugins/catalog-import/docs/catalog-import-screenshot.png b/plugins/catalog-import/docs/catalog-import-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..eb8348b7ef79d1c6cfeb5ec893ec58c70f9292a4 GIT binary patch literal 365037 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%0Di#5m}7P1t1%=W>~m*}p4Vhv3R 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

J~~STi;kiC5F74^ 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®%Zi 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 literal 0 HcmV?d00001 diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 2b2b016716..3016ada247 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.3.7", + "version": "0.4.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,14 +31,16 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", - "@backstage/integration": "^0.3.2", - "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/catalog-client": "^0.3.6", + "@backstage/core": "^0.6.1", + "@backstage/integration": "^0.4.0", + "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@octokit/rest": "^18.0.12", + "@types/react": "^16.9", "git-url-parse": "^11.4.4", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -49,12 +51,12 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/catalog-client": "^0.3.5", "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", + "@testing-library/react-hooks": "^3.3.0", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts index 5abb0e3e53..5237e0339f 100644 --- a/plugins/catalog-import/src/api/CatalogImportApi.ts +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -14,29 +14,38 @@ * limitations under the License. */ +import { EntityName } from '@backstage/catalog-model'; import { createApiRef } from '@backstage/core'; -import { PartialEntity } from '../util/types'; -import { GitHubIntegrationConfig } from '@backstage/integration'; +import { PartialEntity } from '../types'; export const catalogImportApiRef = createApiRef({ id: 'plugin.catalog-import.service', description: 'Used by the catalog import plugin to make requests', }); +// result of the analyze state +export type AnalyzeResult = + | { + type: 'locations'; + locations: Array<{ + target: string; + entities: EntityName[]; + }>; + } + | { + type: 'repository'; + url: string; + integrationType: string; + generatedEntities: PartialEntity[]; + }; + export interface CatalogImportApi { - submitPrToRepo(options: { - owner: string; - repo: string; + analyzeUrl(url: string): Promise; + + submitPullRequest(options: { + repositoryUrl: string; fileContent: string; - githubIntegrationConfig: GitHubIntegrationConfig; + title: string; + body: string; }): Promise<{ link: string; location: string }>; - checkForExistingCatalogInfo(options: { - owner: string; - repo: string; - githubIntegrationConfig: GitHubIntegrationConfig; - }): Promise<{ exists: boolean; url?: string }>; - createRepositoryLocation(options: { location: string }): Promise; - generateEntityDefinitions(options: { - repo: string; - }): Promise; } diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index 0e0cf4b323..95082829c4 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -14,51 +14,331 @@ * limitations under the License. */ -import { CatalogImportClient } from './CatalogImportClient'; +const octokit = { + repos: { + get: () => Promise.resolve({ data: { default_branch: 'main' } }), + createOrUpdateFileContents: jest.fn().mockImplementation(async () => {}), + }, + search: { + code: jest.fn(), + }, + git: { + getRef: async () => ({ + data: { object: { sha: 'any' } }, + }), + createRef: jest.fn().mockImplementation(async () => {}), + }, + pulls: { + create: jest.fn().mockImplementation(async () => ({ + data: { + html_url: 'http://pull/request/0', + }, + })), + }, +}; -jest.mock('@octokit/rest', () => ({ - Octokit: jest.fn().mockImplementation(() => { - return { - repos: { - get: () => - Promise.resolve({ - data: { - default_branch: 'main', - }, - }), - }, - search: { - code: () => - Promise.resolve({ - data: { - total_count: 2, - items: [ - { path: 'simple/path/catalog-info.yaml' }, - { path: 'co/mple/x/path/catalog-info.yaml' }, - { path: 'catalog-info.yaml' }, - ], - }, - }), - }, - }; - }), +jest.doMock('@octokit/rest', () => { + class Octokit { + constructor() { + return octokit; + } + } + return { Octokit }; +}); + +// Mock the value to control which integrations are activated +jest.mock('./GitHub', () => ({ + getGithubIntegrationConfig: jest.fn(), })); +import { ConfigReader, OAuthApi, UrlPatternDiscovery } from '@backstage/core'; +import { GitHubIntegrationConfig } from '@backstage/integration'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { msw } from '@backstage/test-utils'; +import { Octokit } from '@octokit/rest'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { CatalogImportClient } from './CatalogImportClient'; +import { getGithubIntegrationConfig } from './GitHub'; + +const server = setupServer(); + describe('CatalogImportClient', () => { - describe('checkForExistingCatalogInfo', () => { - const cic = new CatalogImportClient({ - discoveryApi: { getBaseUrl: () => Promise.resolve('base') }, - githubAuthApi: { getAccessToken: (_, __) => Promise.resolve('token') }, - configApi: {} as any, + msw.setupDefaultHandlers(server); + + const mockBaseUrl = 'http://backstage:9191/api/catalog'; + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); + + const githubAuthApi: jest.Mocked = { + getAccessToken: jest.fn(), + }; + const identityApi = { + getUserId: () => { + return 'user'; + }, + getProfile: () => { + return {}; + }, + getIdToken: () => { + return Promise.resolve('token'); + }, + signOut: () => { + return Promise.resolve(); + }, + }; + + const configApi = new ConfigReader({}); + + const catalogApi: jest.Mocked = { + getEntities: jest.fn(), + addLocation: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + }; + + let catalogImportClient: CatalogImportClient; + let getGithubIntegrationConfigFn: jest.Mock; + + beforeEach(() => { + catalogImportClient = new CatalogImportClient({ + discoveryApi, + githubAuthApi, + configApi, + identityApi, + catalogApi, }); - it('should return the closest-to-root catalog-info from multiple responses', async () => { - const respo = await cic.checkForExistingCatalogInfo({ - owner: 'test-user', - repo: 'rest-repo', - githubIntegrationConfig: { host: 'https://github.com' }, + + getGithubIntegrationConfigFn = getGithubIntegrationConfig as jest.Mock; + getGithubIntegrationConfigFn.mockReset(); + }); + + describe('analyzeUrl', () => { + it('should add yaml location', async () => { + catalogApi.addLocation.mockResolvedValueOnce({ + location: { + id: 'id-0', + type: 'url', + target: 'http://example.com/folder/catalog-info.yaml', + }, + entities: [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'my-entity', + namespace: 'my-namespace', + }, + }, + ], + }); + + await expect( + catalogImportClient.analyzeUrl( + 'http://example.com/folder/catalog-info.yaml', + ), + ).resolves.toEqual({ + locations: [ + { + entities: [ + { + kind: 'Component', + name: 'my-entity', + namespace: 'my-namespace', + }, + ], + target: 'http://example.com/folder/catalog-info.yaml', + }, + ], + type: 'locations', + }); + + expect(catalogApi.addLocation).toBeCalledTimes(1); + expect(catalogApi.addLocation.mock.calls[0][0]).toEqual({ + type: 'url', + target: 'http://example.com/folder/catalog-info.yaml', + dryRun: true, + }); + }); + + it('should ignore missing github integration', async () => { + await expect( + catalogImportClient.analyzeUrl( + 'https://github.com/backstage/backstage', + ), + ).rejects.toThrow(new Error('Invalid url')); + }); + + it('should find locations from github', async () => { + getGithubIntegrationConfigFn.mockReturnValue({ + repo: 'backstage', + owner: 'backstage', + githubIntegrationConfig: {} as GitHubIntegrationConfig, + }); + + ((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({ + data: { + total_count: 2, + items: [ + { path: 'simple/path/catalog-info.yaml' }, + { path: 'co/mple/x/path/catalog-info.yaml' }, + { path: 'catalog-info.yaml' }, + ], + }, + }); + + catalogApi.addLocation.mockImplementation(async ({ type, target }) => ({ + location: { + id: 'id-0', + type: type ?? 'url', + target, + }, + entities: [ + { + apiVersion: '1', + kind: 'k', + metadata: { + name: 'e', + namespace: 'n', + }, + }, + ], + })); + + await expect( + catalogImportClient.analyzeUrl( + 'https://github.com/backstage/backstage', + ), + ).resolves.toEqual({ + locations: [ + { + entities: [{ kind: 'k', name: 'e', namespace: 'n' }], + target: + 'https://github.com/backstage/backstage/blob/main/simple/path/catalog-info.yaml', + }, + { + entities: [{ kind: 'k', name: 'e', namespace: 'n' }], + target: + 'https://github.com/backstage/backstage/blob/main/co/mple/x/path/catalog-info.yaml', + }, + { + entities: [{ kind: 'k', name: 'e', namespace: 'n' }], + target: + 'https://github.com/backstage/backstage/blob/main/catalog-info.yaml', + }, + ], + type: 'locations', + }); + }); + + it('should find repository from github', async () => { + getGithubIntegrationConfigFn.mockReturnValue({ + repo: 'backstage', + owner: 'backstage', + githubIntegrationConfig: {} as GitHubIntegrationConfig, + }); + + ((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({ + data: { total_count: 0, items: [] }, + }); + + server.use( + rest.post(`${mockBaseUrl}/analyze-location`, (req, res, ctx) => { + expect(req.body).toEqual({ + location: { + target: 'https://github.com/backstage/backstage', + type: 'url', + }, + }); + + return res( + ctx.json({ + generateEntities: [ + { + entity: { + kind: 'k', + metadata: { name: 'e', namespace: 'n' }, + }, + fields: [], + }, + ], + existingEntityFiles: [], + }), + ); + }), + ); + + await expect( + catalogImportClient.analyzeUrl( + 'https://github.com/backstage/backstage', + ), + ).resolves.toEqual({ + type: 'repository', + url: 'https://github.com/backstage/backstage', + integrationType: 'github', + generatedEntities: [ + { + kind: 'k', + metadata: { name: 'e', namespace: 'n' }, + }, + ], + }); + }); + }); + + describe('submitPullRequest', () => { + it('should create GitHub pull request', async () => { + getGithubIntegrationConfigFn.mockReturnValue({ + repo: 'backstage', + owner: 'backstage', + githubIntegrationConfig: { + host: 'github.com', + } as GitHubIntegrationConfig, + }); + + await expect( + catalogImportClient.submitPullRequest({ + repositoryUrl: 'https://github.com/backstage/backstage', + fileContent: 'some content', + title: 'A title', + body: 'A body', + }), + ).resolves.toEqual({ + link: 'http://pull/request/0', + location: + 'https://github.com/backstage/backstage/blob/main/catalog-info.yaml', + }); + + expect( + ((new Octokit().git.createRef as any) as jest.Mock).mock.calls[0][0], + ).toEqual({ + owner: 'backstage', + repo: 'backstage', + ref: 'refs/heads/backstage-integration', + sha: 'any', + }); + expect( + ((new Octokit().repos.createOrUpdateFileContents as any) as jest.Mock) + .mock.calls[0][0], + ).toEqual({ + owner: 'backstage', + repo: 'backstage', + path: 'catalog-info.yaml', + message: 'Add catalog-info.yaml config file', + content: 'c29tZSBjb250ZW50', + branch: 'backstage-integration', + }); + expect( + ((new Octokit().pulls.create as any) as jest.Mock).mock.calls[0][0], + ).toEqual({ + owner: 'backstage', + repo: 'backstage', + title: 'A title', + head: 'backstage-integration', + body: 'A body', + base: 'main', }); - expect(respo.exists).toBe(true); - expect(respo.url).toBe('blob/main/catalog-info.yaml'); }); }); }); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index d1509c2105..b4b1ebe845 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -14,37 +14,131 @@ * limitations under the License. */ -import { Octokit } from '@octokit/rest'; -import { DiscoveryApi, OAuthApi, ConfigApi } from '@backstage/core'; -import { CatalogImportApi } from './CatalogImportApi'; -import { PartialEntity } from '../util/types'; +import { CatalogApi } from '@backstage/catalog-client'; +import { EntityName } from '@backstage/catalog-model'; +import { + ConfigApi, + DiscoveryApi, + IdentityApi, + OAuthApi, +} from '@backstage/core'; import { GitHubIntegrationConfig } from '@backstage/integration'; +import { Octokit } from '@octokit/rest'; +import { PartialEntity } from '../types'; +import { AnalyzeResult, CatalogImportApi } from './CatalogImportApi'; +import { getGithubIntegrationConfig } from './GitHub'; export class CatalogImportClient implements CatalogImportApi { private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; private readonly githubAuthApi: OAuthApi; private readonly configApi: ConfigApi; + private readonly catalogApi: CatalogApi; constructor(options: { discoveryApi: DiscoveryApi; githubAuthApi: OAuthApi; + identityApi: IdentityApi; configApi: ConfigApi; + catalogApi: CatalogApi; }) { this.discoveryApi = options.discoveryApi; this.githubAuthApi = options.githubAuthApi; + this.identityApi = options.identityApi; this.configApi = options.configApi; + this.catalogApi = options.catalogApi; } - async generateEntityDefinitions({ + async analyzeUrl(url: string): Promise { + if (url.match(/\.ya?ml$/)) { + const location = await this.catalogApi.addLocation({ + type: 'url', + target: url, + dryRun: true, + }); + + return { + type: 'locations', + locations: [ + { + target: location.location.target, + entities: location.entities.map(e => ({ + kind: e.kind, + namespace: e.metadata.namespace ?? 'default', + name: e.metadata.name, + })), + }, + ], + }; + } + + const ghConfig = getGithubIntegrationConfig(this.configApi, url); + + if (ghConfig) { + // TODO: this could be part of the analyze-location endpoint + const locations = await this.checkGitHubForExistingCatalogInfo({ + ...ghConfig, + url, + }); + + if (locations.length > 0) { + return { + type: 'locations', + locations, + }; + } + + return { + type: 'repository', + integrationType: 'github', + url: url, + generatedEntities: await this.generateEntityDefinitions({ + repo: url, + }), + }; + } + + throw new Error('Invalid url'); + } + + async submitPullRequest({ + repositoryUrl, + fileContent, + title, + body, + }: { + repositoryUrl: string; + fileContent: string; + title: string; + body: string; + }): Promise<{ link: string; location: string }> { + const ghConfig = getGithubIntegrationConfig(this.configApi, repositoryUrl); + + if (ghConfig) { + return await this.submitGitHubPrToRepo({ + ...ghConfig, + fileContent, + title, + body, + }); + } + + throw new Error('unimplemented!'); + } + + // TODO: this could be part of the catalog api + private async generateEntityDefinitions({ repo, }: { repo: string; }): Promise { + const idToken = await this.identityApi.getIdToken(); const response = await fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/analyze-location`, { headers: { 'Content-Type': 'application/json', + ...(idToken && { Authorization: `Bearer ${idToken}` }), }, method: 'POST', body: JSON.stringify({ @@ -64,41 +158,23 @@ export class CatalogImportClient implements CatalogImportApi { return payload.generateEntities.map((x: any) => x.entity); } - async createRepositoryLocation({ - location, - }: { - location: string; - }): Promise { - const response = await fetch( - `${await this.discoveryApi.getBaseUrl('catalog')}/locations`, - { - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - body: JSON.stringify({ - type: 'url', - target: location, - presence: 'optional', - }), - }, - ); - if (!response.ok) { - throw new Error( - `Received http response ${response.status}: ${response.statusText}`, - ); - } - } - - async checkForExistingCatalogInfo({ + // TODO: this response should better be part of the analyze-locations response and scm-independent / implemented per scm + private async checkGitHubForExistingCatalogInfo({ + url, owner, repo, githubIntegrationConfig, }: { + url: string; owner: string; repo: string; githubIntegrationConfig: GitHubIntegrationConfig; - }): Promise<{ exists: boolean; url?: string }> { + }): Promise< + Array<{ + target: string; + entities: EntityName[]; + }> + > { const token = await this.githubAuthApi.getAccessToken(['repo']); const octo = new Octokit({ auth: token, @@ -122,28 +198,50 @@ export class CatalogImportClient implements CatalogImportApi { }); const defaultBranch = repoInformation.data.default_branch; - // Github search sorts returned values with 'best match' using 'multiple factors to boost the most relevant item', - // aka magic. - // Sorting to use the shortest item, closest to the repository root. - const catalogInfoItem = searchResult.data.items - .map(it => it.path) - .sort((a, b) => a.length - b.length)[0]; - return { - url: `blob/${defaultBranch}/${catalogInfoItem}`, - exists, - }; + return await Promise.all( + searchResult.data.items + .map( + i => `${url.replace(/[\/]*$/, '')}/blob/${defaultBranch}/${i.path}`, + ) + .map( + async i => + ({ + target: i, + entities: ( + await this.catalogApi.addLocation({ + type: 'url', + target: i, + dryRun: true, + }) + ).entities.map(e => ({ + kind: e.kind, + namespace: e.metadata.namespace ?? 'default', + name: e.metadata.name, + })), + } as { + target: string; + entities: EntityName[]; + }), + ), + ); } - return { exists }; + + return []; } - async submitPrToRepo({ + // TODO: extract this function and implement for non-github + private async submitGitHubPrToRepo({ owner, repo, + title, + body, fileContent, githubIntegrationConfig, }: { owner: string; repo: string; + title: string; + body: string; fileContent: string; githubIntegrationConfig: GitHubIntegrationConfig; }): Promise<{ link: string; location: string }> { @@ -212,23 +310,13 @@ export class CatalogImportClient implements CatalogImportApi { ); }); - const appTitle = - this.configApi.getOptionalString('app.title') ?? 'Backstage'; - const appBaseUrl = this.configApi.getString('app.baseUrl'); - - const prBody = `This pull request adds a **Backstage entity metadata file** \ -to this repository so that the component can be added to the \ -[${appTitle} software catalog](${appBaseUrl}).\n\nAfter this pull request is merged, \ -the component will become available.\n\nFor more information, read an \ -[overview of the Backstage software catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview).`; - const pullRequestResponse = await octo.pulls .create({ owner, repo, - title: `Add ${fileName} config file`, + title, head: branchName, - body: prBody, + body, base: repoData.data.default_branch, }) .catch(e => { diff --git a/plugins/catalog-import/src/api/GitHub.ts b/plugins/catalog-import/src/api/GitHub.ts new file mode 100644 index 0000000000..6a798ec201 --- /dev/null +++ b/plugins/catalog-import/src/api/GitHub.ts @@ -0,0 +1,39 @@ +/* + * 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 { ConfigApi } from '@backstage/core'; +import { ScmIntegrations } from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; + +export const getGithubIntegrationConfig = ( + config: ConfigApi, + location: string, +) => { + const { name: repo, owner } = parseGitUrl(location); + + const scmIntegrations = ScmIntegrations.fromConfig(config); + const githubIntegrationConfig = scmIntegrations.github.byUrl(location); + + if (!githubIntegrationConfig) { + return undefined; + } + + return { + repo, + owner, + githubIntegrationConfig: githubIntegrationConfig.config, + }; +}; diff --git a/plugins/catalog-import/src/assets/catalog-import-screenshot.png b/plugins/catalog-import/src/assets/catalog-import-screenshot.png deleted file mode 100644 index 7ba8ae9eb53c078010bf2ea461f015783a46dced..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22602 zcmZ^KWmFtZ(C*?c!QGwU?h@SHCAj+nVQ~mfaDux92<{HS-Q9yNZj0N^`+evBy667r zo|@;Wsj04(bEdl{N=-!;4VefT005xL%SmYf05D(x0BRf&<{#p?CTa8U0&63oECB%2 z#iP8M!Tr03^3af#1XNFv9RJe>s3~ble|&sUxSCVBSr9rHbNV`S`MGfTIzK)>;^X53 z3>ES14N06#sogCf9v)bI9PaM!=z%s&UUoRP`pn*T>>wvhD;+Ewz3uI7Za-I86BSZt zQ*sxxi;D|lM-x0dLs}23ot>S9g#`vroBR9wwY9a=)6=oBv7@7-+uK`sQ&l80b>09s zC?h2b3JU%}kGHosbW82`k9RZ+EfEosrluw^7>sJ6iDIt#`Sa)8++2}RFUTF_=H^Bq z$U`XD6UJCsKR%3*fPmF|hKY$uIK)%T-<^nnhta~%&i;BClT|MvCa^@Eb4!qxuFkHQ8%M#_Tz?eE`1w=V9} z)3fIPUR>`CEuG$%RM*o9YEsa#_*tsfS7b_lk!GN$Wu_!8P4Q6@xC@?L5}l_!|dz|o9LD(C-q2QS0f>EOQ#5NlThWDkZ?D1F&(#oUpdx=Yn%Jv zKqEoltF4!ldE>+t8Up<0j^g6t$@Pv{P-d%Xq*HrwvrT^!@b?Cjs$=(0FSEuvQ75aA z<~=5_w4vXP5^=H2KT?y6oE%gAGlA+tLG!r-1GCl6vavM-U26%Y5k!v0?7_jlvq#A} zOY%`m-#Uk~8*;-BcCgv0S^)qGfV`B1*0;5VY}LrUCDLK<*P1wy^lLp{GUD}Gk;#=k zLNqIc=BBccrpAe4Oct0`H3f>4V#*R61NJTgas@;jNjs!=cmws3Ccdgt2?{Ou6A3=@ zd-h*nHN866Q|{n-AtLh4R&V}ER`K^Qe?T-ooqcQ5moG1GTfXhdahc$135?i!qgIS? z=?A{DeH9DpwQvjh(l3_U3NJ2I`ZgU7>Rs)jJnQ1h4P$t;p0s!hcGO|PQ13Y^rolRrbX%z1zPMEO;)# z_Qe?4!?G|O#LmeEjz@}X>bON7c=ssblN6eCU z(9~!UpKP_uV1)zZF7z3MyUj$i1}^Qc^+Y)c@^E@A*j!!oKFlU2A+6AHFZSC#1gDTw zsEH06l?g}ZV*@+(3{^T`rs26C{eN|0OQ+cMtEc`2>20r9)fi_uQXj<$0J=J_ZrjS( zj7Vv{*;?D4jh$L&oDpB=lT61{{nffJm3_ELyQ)Tig1zVm*mtTCHW4;UV8v0O$CUxD zGc4S^GS#dYk?M`O>zQPiVR@}-oSy!_wXYv_M78g0Jv}aRuTndN+Cf``i%#si>hXpg zVjO9QWo1zA_af8TBJFWoj%?u#f)YXA{PET=M9*t>si3a+OFE*;D+lhK)F+;h-)MZ= zdk>PaJC}?mY49VriK2@4N7f;ll=vL(?jFbr{L1( z@$Vf(fJ|lTtKLK1IB59g2q-Ap2UDqFFS5}{i^|^49i0!3Lp08iij4V&EB@_iM;(u) z#Q+kG@?5zB`b&1-mho+4;w$vht(7$vgU(mj40qGiHr(4KLv-Bh#^2HYBL5dXed`;)!HZK<{ zFqj>U3m#nR2CKH7`P{Mek;BXMcL*mxCw@|iU!me;jT5bb>H4pU!@&UQOxftj-`C<} zjSZS<6z2-ZBa-@u`l8CBs?6(=En|ijeHGjo{Bvdxawo4Ac@O#t>SM5#i@qH?34A8v zXrN6StZNYIy6|7t{byZTk9rM9F9w@BUM7vtZQGGFpv zCo*nE1fLa<5j(#q2hBX@g@xRxe0v2M)Vjn@_N%OduH3t{jl}u1BaU4;mOKlZQ&m?c zeswIKFx)!AA+&Mt7%O$DvpCV&n0AYN0sSltS*oQId&0R4#4sVsYe!1K+Jh> zJ;^rr!@L#V#i4?74dcYGz`@Ypnd>pC)KqHK^l1!i#E0`gUX1)2_l?dsE~SW##63u4 z@$K{$HcP--a;@ve}RALK8VUD`F$azEWS z-2_RisPXf4_*&b1t~;VZhnB`E>-eV4`N=nObTr|Kk?XFI!LtbLt5FK|e3Cd}`C)UA z!W$jK>F2=T_zs^mC%&F8IaM+y*De~7CY?Jde?A*K&#mr&I)W4Q;m8CG*tsoqz|GlN zFmoSJ=`O44aW;KdL_}mpwykXRa>l*+Oj7U*UQhOHGN+nwo#NwVu>;9l7h`xq-DoqQ z(!HrQIAs$Km`Tp@nI`)yfq_WBB$_QTlRl^`UkK`w5CNM0!>ky(t!k3k^=sy4*wC{C z3$e5|k-+7~Cg*f`Sc4O8iPkh(L>&w+l!wp^hL71F6mzj0GURN7QgTkzVK-D8r|Cp$ zuVcN;?|}CCmRJEsE0j)<;4U9(>r=P$D@RL(kVE4-|2=8#C*TPANK?2Tj=TI1WIPO z$;Lqw)F1o81%D@>k0{OE?1VyqB9tK1U3w^0cmAT~tzpvkV!y6~vQ>JKG%$QpM(Jp9 zt$~&oKA4zT*>57TeJ|Ba|K#b)&TBq&2|4PVa#DV&iXyP_2AMuyz#4H2u*HleAu;S1a7C+kyQS;1?@p`scAk$L1zbi4uQ?j zqmwq1odPZO2lNErME2-pK*z<^1zAz{h+aJVmjY`?a zN-6;(djtnw#1CP(ldqB~dx3oY6KQt<9YDgBB7;pKgO1$(vmM|4+>037rCPLS%ItM< zak_e!&*Y+U+ped({kghqGxXvSnZy>(zieK?n+uRlKDio-(|D`00*gbnL@0LADL{jCo(WhoAUM-WDs zxKp8%;;Iq(*>&QFm9&yaZ7n%_|7c_=R-y!gDQ;vOoh+NMFaF0(gSQ*W$JL-1c{$t8 zTnqm@hFMEL*CTj%R8i)ERrlS=3$xDn83)YrSfF_f>MT9;b(|bO$}ef@~0b7 zpC2f8_}{;zTE8A$iqA|37L$p*qVa?7gF#R|9#8dZfc%~}-Z-)MO3)bM^FyD@D=puW z=o$E05f`GE>hwx76uFGiH0|?yn8dSs?!DJ1j}S0Q9>r z4MMaJkzT9t(uwC)@)s2XvOh5}W;-yD1DoTD{39rEZ$(y*2Iy zL@yR_cl<@%7V3q_gz2`ocDslI{P?8ml6#XU@@KlIt!>E#4nX>Gb(?jnyJH;K9snP3 zn`~0>^oiRe`hzDfQ2rn{wC8v3 z{a5*7k(2j4cAf~MkLA;~z?P~-3);}Kyz?;Gwwxknk+8CFR-f5Q2hcM<;S86)WcPCX zjf_Cq6)-DQu2KIJ6c&M;^HZki-EzPcnN6;kf&C907wlqCFA^tcdGqyrLPG4rxcll= z&i`qf`t|M9@7-_&D7yU$7Z;cq_>S1)_Y`fTFFsteth5Qzwf57(k%fm|_@ej zh~MsLY4$m7E$VC1w)FgN|Arnrrz-}oTGZ7|+$c(WK0y}#;K@u0Eh}gY>@o=Ldp({n z(>?WeuF9|JY4}t3H5^eeC_ir#8Zi5c35eV$CI*PluuZ}k2dY(>@bIdNi|?YzjJ*>| zw%#yCzq8Doo2er{hY36$(leZSUv9lkADoOnaDds@_`G&!>A~Y4QMnZFQB_b93s_Ol z0k>J+JRCYBr}tzMtNePkp$pnwP1CgD6%Ui8;sS0+j&Co%HKaWslN;U;t24x7*Rbll zrS6^wiOaFW%s05Re8UzfTd0|77bu$bBQdXFlQzpdA|8KIIO_>lnRvWa@>azUHJjG6E5EaUNXhD80Hd(;eij}4CN~GTG)iVuEGwCBTAMk~w&PN#($tB- z7WKUdZ4%}=ZSNKxEH@i`PYr>OKZ9P%i5$`B(uUAS`5N4lhAawJp37@NGSmiJ?*s2?) z#2tJo*vxl;nyE~^%d(l_Z!0e-zcv*Eol4#9=W(L|fy4#9y4#&kjh?!7h5a9=7)*;} zit$t0l`hjzgzu?i<+3i?6uW9;F8MCetP&@rMf)ch*MMMp7qG$>TBB@;Q2$tK(^jKu zivQtWc4LhyQ$_osRtfS_`AH;-*Q<)&(?u+04vB8U9{Ot?TilAMr{xOPTm&P({D>2{7&2$ zRC=>4uoAlVhHs*zslN^FcnB&z_tDDPhZQ%qq1d$k8RDLf3$koZA;l^tO()f--g?K? zgekhD(Z(1y;z12%Q~d4n6f3WFS5RK`hyBuVhUN5Wn>=f>Y%Doz@(xY_Fb@i(EbEfnG$7bNNXO^}r4s^ejO;84-FYZ&JX%^a@_Od(Ry{j<&pS>*2RfQ_ zR&A0`n(0h*K2l-c#3AaSUMIvIdV&HN9|v>jV1U$>gkJw#6TF7XYzCW~22;j|!$7?b zlcMOCIW`E}p2Aa?1xfH39}8ZifUrtsczdTiZofsyBw>=Meuv9+G59g-O0%8Vm=(TH zpObe#a==xP>6|2cFr(4ttOHzQu%mlSNGJ@gjaae2;EFDPytMrks=^iFwVa(N?+Jyf z(o0ereHAcEY)@O(_wgM4Sx<W73P-yL#%_6`wy@H9Vvj9vSJ(5j#oM;oSEA)m zfcj8|lVv8cu*f1nb_x~L8g}i>GmYJZ&iUjXfkkR&C0%G^pIA&%;)8Cv2*^ASR#woQ z=;BsfD9>6rNYNsds>$V;d==LXCF_p;!&?ld3%!v|2eldaDBMEJ$DY777KkifU^S$E zF@)MT&(htaMGUXb$T2tIz^^*7FFfD^{f^?LIWLLIV+NyNX05MJD+jh+qspGSWOR3R zb$w#S+v(f%_WFi#{bOg@S^cN>mxz_kOqH=3#a4iY4=E7#vZAJpOpYSEcz{z;BRU~4 zEAp`mTVfa&@eOw(k`@Kw_+W%f1@7bTrvnUVTf5Ti=?BuW}=J9cf%vR;g(xB0#(cm7! za1xTyCqxM}C69WMENQ|rpK28D2#0r57v2+nvnc|N06~If0=ob_(yjUX5mNXwx_G~OCIAhq3ttn*F1CINnJJ+rreNKG67{?d zuK<2$a0~q~#xU)C>jJWaj)^9{%eVNV&^s+TE0(Imu>2F~Emg1@gx|6EpgwP)eqp$7 zA4~G33($!E-kNm~RSiqJOefKsjEvy^4GP7Z_))Zy_6GVqxcADO(y#qb!c0lmxD^!&knf3$=_BFGaMT%*=?q^#h!1?5hg1QuxdJ52%>V6L*Ou1I+B z>#Q?iv)V+w!b#i>+R>RpymK85vif5aNoomYj}(^VGb~{)-j`~$+Qk(|rewcxqi8+C ztXSLW!{i+koU{eQ4X#_Ok^SjUE;a2;I3AXVz=t3*=UrM=FJYFf$+?r9S)-tqOY*4h3BG z`{Z0GjfmPWaDC*h8=L7~f>9gUNV5v24h3iKr^>t^Y-EbDSaMk`q&%}n!2w3K z=&=S{4JCz`Wc|LR278%n*^kzIwW}2_BknbRYbHiKf78qX1mBYeMr`vT_YyqYC|VM# z46sHhYnD!lAkie1gW}SEN!l(O-{4fn?~*=aG&#P`v|7FGsf5^;F((ukgCyr0VC2PX zSWMd!XI}FpCo{l z|I>(Quyc7pu*R?NSjaPDY1c#03$tS6r&kN?1AF=zwm=}t6n_bX4SCg<u zBPb5ThLRl5{}}kDHvZzjYPTj<&Ja<54n^EUDF8Tu^Bc|1jcld>x76$; zwmz76fP|%!AYZfQ%rLpewKB|4C)m zdy%&3%g5D}7}s4eVNXJLAg|&V|Alp|^%Y=g`qWY?&`Pw__7=7KBd_cBhaJ`A-s3@` zDBHcvrI)sAywS>e{SKX~xnnF(((gHN_sZE8Z8Bw@wPCzD2J*QbM~Z{K<-xwHi#!7! z)#u31M6t**2ndRP1^SW{R?=xBxaQcqaOn_}Hhmu$wu=Yh`MKctf&~qf7r(3tr82H* zVXldz2bcHSKWT~<_xqUfCsGklQ#lfwep1hG!STyzf}$U(H~aF7M@;f8DkW)#TG3q4 zKbf^d5Zfh7a3HVYLEUj_j8)5*5s$h^>Vp2qA0+XfHuLQq zo97F&$du|zV$}XxEM20D1bB86c*4VoC);m|3ci%xU@j1?&=XHOxir>C(91*mCN$9_ z_0#0qF#Z#urn@dZ5VGWaJ%ssuozpu?{1DxW`H<}-zE1|j*TX_#`#@H%Ez~>&Q>Bh@ zoVb~K8*$}5_C^PmZs;-5){7@W(zFul4ql!${V0*IoCxA4oE{PLLt{s-A!lBAn8k=K z5q!P4SZU}?RIOx9CLt_S55Fr^EX5w}!|gjQSl&(ULMCjhdHS*}g^S!diNe3VQBl0A97MC+qI#jIQhEOfidHt8Hw_;+LypxXlXZ zHv}(PdXqG9P3%>gU3j*TJ-uA>VLTgLvczs})@UO};yo-^KvmNj3pz~8zL-~w^u!?a z5c^NEWDe^u=_kfC&UC;0IC%@56<#nObQvg`Z=wJGEpRnBw9oA!kjBL*W)2$m@FVy` z^Z?E1f%?~_NT=A(l*{dxfHM(Sg3>SjWbZ0#{NJ9MCI*_Xru=08fUEqo4v%?8$2Fk0 zLxJqsxRI&zrV(zD-aF(l3u5CPzw6hC7Q(|LlqVhbO z{0R=wc-(>bDk&*(EF5ECO&5)8YHB8@r>C12YZb!6ikxGr3;w4o<))x0KGUH2A5(sI z1`GRU3;z#S^5lk+NaNt(7}P^T6dWEHz_FmE07pheF%FsMb8&FY5Q3GIP=WCOY{29M zPYn!++x!olk)BTQkNHQ@(b4@stOeRL>JY|KhhaP0?O*rDaE0+jc6)|c+hb<@TNs=7 zkn%D3fSr(+&kO>9UspHR_}sL_M4v!R-2>T+S<|oZ&r2zj zw;nn=KP$syIKa_T$)XHkbZPB3&4Lr5b3QL=Uo=U$sT-jtmED4<@VX|O6zwqH$d!oH zFx9Yo2c6hdZCKdJR(DYZnfWnRmN{-=-0u;5JBYbXpZTCAkPi^YoIbz&G2vBg0V)5{vx5M+K%DV7}*wu=Jp!yScPwGALW+}!In=JCIS(y z&%;wU_vDR;BG_YWt6?=c3UiLt;>}?Rj@b}<@fhGo6Z5{e0}YwTv;%zp@B{gHD&Mh* zU3Ux0O++oh9XO|dtU^%Bb-nyEbymKU_Q4lf*szKOg2;LJUt`Z(1Vjox+5Rxpniw-CMg7j!fi(66IYP6m=m%^sKWwQ!ep2BCjT~rf@hk zl^Kco@Kf<|UDDIRBFPC?IuclzOu`1OGsrCsv0c3M$U_jdF_;KIYWRE06S(}Xy*+Ur za%ELf37tDqP~b~$42tuZ;n*&ep8j2&6NM#QbN6Dh%6(qk-0;15-hlENOcO{x^fe}O zmO?QnbZc%f`u3OX@(+uVPsrJw?#+$X?AX|DpH&`v;-pX0>?JuGm5X#KGT|9(opS#-7l0%wV}Xuk>^mEe0HZzC zIUayib;}ljYLEnCcY&R$^Wnu^v7bFDbzAi=-{ys800?%MXOepCyhk$6 zE{ClR-fZIx;LHp9R^}%BZ`qxG&jyU!sQDIKg%!EGlNc1~y)1=JWF6r#S#wq*D}H>( zOYZq9!tLS%s+pJ%k|v&nUNc5W#^1Mi6LLbrZA@#L*tvU`XHZg$C=|-Jb ze$KGnpIgoJGV1ts>-f{=4|OW@I!a?>6sd=*0D}4Db=a5b4q<9|;+KChv$qGI+TeH4 zzlL{`h`UrtWYfqVfdDYj#Do+51);d)Zp;#SR|i7?5Qh}v7od}@ojz%h%EmROqmj*U zvH^z~onafF{hr?-KZk5!@c38b`gBzByzh(E*wmU@Yo?5ov#J77M>75;wN7@6q$EJN z6>uXkWz%d>2Fyo*ul2HrhR$oZ`=r0JfjNy~WPfsZ1SUMr*dP#qUnVxgkk_v5K?mJN zf5{0Q=;d(YJP4&Xb+@r9cc4&x$mMflWw9r|4t@s|z)goB6woY(AgH7tQ9H4Y+-^KY z#G=PTj;-WhblFRf6d$q2Ne+*~lN=NHSS8cI?H#2aPW*v02Jx3S&=6 zlV4;!y1xY|Y@S^QUpU|YYC=(*oJ=(!2P+O8w)r($NzfMvSs|$5<_Mu}B9&)DZq`tQ zRn0=Ra&dP23w(KyW~FC-V8y>jIwvetm!~p7eF>T2(?Q&fVDxE(?Pfk;AaY>;nuY@* z;{fL2eep&X{CzvKbBP4^)}DNLHeT@JzDW8lpoEx44E^cL1OAPdoU-e3szheFWtnS7 zyQ#TH4x*rAm!u<}avviVfN=o7(!N5r8=L4)=h^@$E&iRxC5eJaIuY>=) zrg`x9i;S35Ljs_OnF0cPK3sP6WidBDloT7dHoEO5VFLf@MmYF~$CY2q`51pxf(P;S z_bT6SxZn|#XD)WC=DIaSO;}+1867S^;?GHM-;Of3%JN@QTjq+#3}McX;+G1iaPb?? zM5mM<{`via!jotIbY+Vv-7zVXAo=8@pB_L@1%)x(ofe|{XL({r7~qy>j5hKRH^16)*aY+tfM7! z?eJcU+qd&v{8Z9`G&-(wIk=DYS9b6cBcp|A);x{ane0QINn$1BJnfULjdlAEQuclv z2IN(?%;rBNIB{Mn$1^cUs5%J~R7QV#6m{y#;n#b!u27B@m2mw-ed6vXUu9T&6cRZ| zZ<_t;dotbf7-2Myg!XlBN5>hyV;fbm{cDQ+37?%s=F;xKr#Gh>Ze^jUB6sH&2q;-mc{dR< zC!-?#F5c{)uQt|ZMpM3K>vxk_!d*2=0)D-TGFje{ z5tvVCA@MKsH$s^{XO-r0Z3?s`skki4xf}AxtiRgA$rvM^oHDW7L#wQ2L&Lqfc;Frq zQX?fjU-+&&(Y71B)#OS;j&LioLFesy{*CTx_!zUocK7O(C;CQ=RKv8IztzZhF@iuHzqi{?hTBfcXmI&W(3;h-B|$khj+JYg9Cb zyD7J*K>P2a?iuE5e-&TL^K;35@_YNCT`qT@dG+7fRK$Ym^T8?}rujuomU^>dX@79o z=$(!$jmX+w?39;qUbXu!UD>+4&m5hc8l{sd1VxGHaYb%v5n8(a>G0plXDwVso|kL5 zFjJX~?prhgyQ&ju)7AYvg&01^_sPXEI)uOTOcA5i3E@RQ(*GT7Bu~|NI=ymsTF*4{ zOJGqHN<}b%5zrCg5a1%YXQ2Rqe}z97ER0DCpfZ8Lq9I5j1R>%ZXOB2Jqk^V)8!-MO zWFg@=tZ9ZBnBS(;7>mfpbR>X)NQzm-hPHH^Yq#0FIiSF{w!#DVtOnV(G+QVbfLU62 zTQ6nwbN?|2j2Bv9?dfViX}nEe`QSRtUDzGWq3lH-i4HJh6pWwReg4GrC_Q|s6L{YJ z-lfE7=x=cQZ09wdGJ0i+`_CEnAK<^@Qb)JY(%zy>?OcU5V=1Du+C-C7{~0ill`V`x zE#by{vnBPfm9eWfy^rd))wWVr{?Cc-LK}K19@vmMKk|PHh2THo|67$&3+7c%K+i%^{E=40bdx6euYdF)mqbMM ztwxEB1(az2bs(+?3oSjuwm+K4w(u)kZPTiWiI$VHm)8wsU9+v}ztEX#LV9zSTQ^ev z9T+)ddz!{qOt$&lN)U#CyR7jR1#ySW&dYdM?>z}oOE|WtPe@39 zxc({9ZMunqwMr(}SNr-{vnnPcB?LaHF5Ve@Fuv*c9a=j=-ghm5E@K8kW4+O_Mdy7h zCVx|k4oJK6V+)M&*);$2HSrXoa7;<=Lg|2VmbRab332pFD=fhHN32fa&D)=Q>bsEU z99$7^QO37>>1QuMVX^lS4AduUf%eI;MT3N>*9=nS8F;qoEX-&c}@dL zuyUtNtckp%GFGgP7be_C`q*VvHm!CwdS3&63&GJWQ?Qow%V8p3p{?V+K0Kv*n?d08 zrGmoni;k9Qazs64Nts|nH*L@DeGYmEL6_ByR(}a{wlistvvaB2ysw7`-lbyLbCZ-HmcDB%37?J^tL(b}ts;57H@DIqC?gxTVd%yKHH$DNRedEr>9o#Hu! z-?v$vDFrTFc8r_4e`#{f=S@7-r`Q+H>3Z@$b&r}w1Zp&?Op|s`h)JnJ02}P&O{)UW|QY*tyTju3nq~EyXCHtl#z<1>Hb=>ktiYy{o z>qi__d+%02&FN2Xua#)8HRpxdhMv_i1;ZTv!qb+5eoTJj*|$YEvP-sEVVv1Czb217 zaN3GOyQ`L2`o;ni&YaG))KAH}M;D?+pYepokYQ$qUy92py3xYZ+h`lzB4^m7xYyq6 z31H#@;xx&EoZ3P8&Q141Y!qU}lQW@bS}ja*dE^9eMlNcw{AQ6F?V{*1q z*$AQ_Z~DZBKvTUCW?X*H+5UqE@J&%6Is3VO#;>!i>g+mon$_cs+5%)9@ZaiD7-$6fTU6NK z38CzN-3CpzDKt&JjO}T~oi1H~3~k!!;1kS3jE9+xTM%MQ`l3x1=0AE5&Ip$ac1R5@A$BjEmZ70Aw1UBd};Y9@VVX3`(=I8>n-=I zX_X-lo}Pu$xtKw8R4eoYbJxT|^(`KP)MfF@r2|Z1Sxwxj614*<_TiBpgt9#l$4W_b zwncMS15O*?G#!st7?9N4xR^)KPvl&F+SAr$!(VIjYEU=R)R6ntuN zXYJR&a+_3!mHyI;YIJT(-{LjNoHx{5FFidkneDc{FnOv-+sNv`e;^lEIPY)ToJ2l* za0uxaW~Y7^d$oo?oX%U9yE9EO2z{Tt%odTTCl}v*L6J6W!4opsFxBIo_b?nkoQQfJ z{869>V?~%G9WoI(lK%MTW{++UEGOA6qB2MnD?%-Abw*{iZLmea$x8DFh8jv^_V_se ziya9XJb1!rKI)1p!|DLpXJV>&!p37xu-<9$XH|ZTu=N>&ebjILmWemGb>;Rdcy4Xj z8IPH55c!+u#cL{LPZgky{1RbZ*pO_xATjVbr{#&wpL+apoZS z_+}ZXESOM`=^l`Bz;(~Yr{~kad<`nOCrSE+O=UFYRykaWF=D|m6miV`2LP$Wzl;$~ zaReYN6l5;w9rwKoQR97v`P${*d;b@I%8{%Q$WhKCnE6y}RDdF-#7f7JPc4@r$%l0J z8D9pR0uT&Ip@w7%cFXgx4of>>UN=yagSxhQp#R}$z^1}!8XTXt$L|7QtO&^(UEy8% zN56Wu=_5t=FF|n>C8(Q1e`sw-zK9Kny|r7(cNm^^H;6;h0DrDSHesR;G3G+)xW!8qTyRm3MT}cmYf18 z%z}j7l)G`Wc^<>yTWnRd@Zj#k?*XuBtt8eJ``^$N;6XWNfy3*&xrz9MLPUqcf_9t;N*mlZvBNH&DcfFPrX3gp8G-Vlr9E3U1+L7)Yp z!Bz&dK1rExasW~&h{%K1iZI%@rs|-i5T3419USNEA$gl$~ zm5)m3Iz7IU+6w zpUcssDep-KmIUK0g1IhrfiI!s8r)>!2oUq%;H|Oeu_pkoQ^I<(jEfwZJeYU7qB)C- z@~I-`tX+1~%%(vY5Ah|lvSqjdP88!!$NxYtWGjNTQU(SdD9$z&eNokUf;w9fJihJt zXfI845*F&=;AD8?Q<_2u&S5J_5m{}O9D7nr*Y1Yvat~0;6qAt!kj}(B5aiUqI9AN& zRjQ~E=D>nDQM#J%4W`xb{z=9PcG5q2oeQRD+M?ci&)fN)H~tHs3Gy%)_?hts9MkWO z>X&Oh@gqmx`&d~brq6`nJ+q@)jx|iWner9uLlVE0V?iSCHGXj8VnG`p5CT>w$@$j_ z_-@klzNaZ(0Y+JeDnn0mXZcC>T;+y~L)@o)r%4M*BY4Znb!K7(j^36c!^7E(_>5OJ zj0%g`_1|))l!-IZ@?k)3fnTjZCV5y^Jp*kl`w}+R5=bS;jWT6ylK**Fax=!xr$U=7SILBUj`cvyk?q& zwTDj=kN!*4PTNH?V+$yK+=Cl#V|`05)4gCO+az={?!0~*qyFDVDsju6L}l14(Uyr!c|V-iTfu!m<%yCIAEgLmcNzgYr07Ei0C?%d#LU%{WaK36~ryTyiW z-={pyzBIL#mkumlX&>d!h!TRq7mm-pYy5A<(E>T0lW2$F9LdX!yJ~t2DZodbCQfAUcr-P!B3SM+T4DlPXsxTLqO}L4eg86SP{u{B?+TAUhdW=x9 z!$O`>f9?1lS5cqQLAtcXqNhan!$v48@$!LEyWsp0{A`ngu%+29bEI3lrNm=jQQ4L{ zWhq$lqTVK^>(`~hn3XtIj@`EU!~0fq$ylpMpDrHX%Qso9;B9HmvhwnjNY-SgfHo(u z=Wj30D!cNib%0w|65}rN3x_L~tMg1JOZZ&1Z5X%p1!<|Gw zq?>Gum))p8v3mgarOC{8v@OHLRJs~B+y;BMF!Y>nxHq)8e`z0B*JRksg!GON;aj11 z*+4%RNFEWjxK3C+@D31KuM`p#YZb{?=An($)CZuo?X?QBuD>wBY+jgQ-!OFexPP@> zn@*BW>#TN|pesK}Jb1>nR`zlFEPXLS^xaK_Yvb4qroJ-P)WS@|=0T^6QfQpp(3=fo z&eu9x;q+B0DJawVFB91QO_BoU?TpNdF#e3QK#6dJmP_l`{h=Q#`ZHF4xb>YSkAQ$q z3oUo%eseL0gyE)_SA)g!%ASeUtG6$S<@~&(S1e*>-(INLcOzYO9S4Wm+0x_2SzrrZnb3M;c&Wbf7Y$Rt<8k3j z0rADD3#F{l37xactgB0Rwrs`6E1@IzU0&7WG&jSa_VXjW?59g5@;2)UrPe8PQi2&( z;Igv^(4Lt3xXK*Fth{~J$ukZ;mUmu=3!gD-xMuly{b-}W@Kr8tQU00Amh02dIN@vb zuJu`N|IEC6XZ^fi3Ei?6$1yLggYi7%II6bD*wmjxbGC&Pf{*>u*H{|lwE7p-uJcfH z)H5u#lj5<@4W^|C3RNW5m*U#z1VuN9h!oNAH&21_vfr_7p5CWR1CJG7{7Zagfps2b z5w%&U=dv2G*zPm`;8Y0>5;V=c=du%9wIWTESm;(L6~_UDJ@{hAD!(BhOeOoY-KF>< zMed4VZP8*xSO<;Bij_(i%~y3(Hb0_#hP7D)6b>xrQ2Fjc@u&HjDmb?aObEsUttTBv zS(G;PdCx?=#dKYZE@&lJg0ESjNloy$0!YySsi7Tj@tENg#Dg3*V^o`L%Lgo#)`5=fC2`+M=^uM89q-)s-iwf z!w`a(h@iV_X>D&YS*!1CEzm(-mSXKDoJS;{)&-|?8vzsHn|~$43pyTN3T+U9_TNBM zU#nE3^-C*oQ7`mloa_M~mg^(~wJFYe2bfr|-$2a2_*f{3ff>NXW9!@WoG3z3p3l8Z%Wor` zp@$x*_TZ(XAy9+={W-k}q`B*n1lAceU37ZB$0$>sm6d4&k8tz;Ox(%z^p@20HAKUJ z8+|3ou^|>rvrMn?42XSyBO-}DVmH$NRpMMcQ_Y`q-CwVVfuE6g8-2MZ=&zy!q8{g0_YL5bFqlBmlf>auF z=#g;x13*(vJCTa4UJ0~`Q1SiUEC1`Ce~$`i;n6JAfEW#Se)9`{XtkCk4Y?3-`m8LB z)Z<2f_0Qb?xrm~ThrTw`Ym_Ta15L3k>l!M3r@K_ziQ%S%WsaJo#3~-;SK@5_f(VGB zVwa$r)?ddJmTFo_=-NF|wb_c&^dq~J@WMMTQD6);@W;={8lh8kvuJ!Qt>+@hl}n1O zxmKG~j1Q9^8F<-h)h!vgsD&B0{Rq!VMRl3*r!T-KX+Pc5^c;{Ushx+F6O=7yaQ%i# zCL=vTP>dUEtTrsZi{e~XBcsR3vujfD{w>yoQpFqaSG?E|lE96~LI1;?K26WbFmsIs zYo8QF6EEPbb;PCC%xuGA}so_df_e zt!yy8EEnig;l5oQg9DAp)rKxjc5zkB!CAl(8ylo66$NW7s=b8%6rx@Dy?IOTxy-%M z$XqcH?cS(Zlacz<-+njS5R?ZxzIG7~M3JO{&W5?lo|V zB-Yy^)v;1`?j7NGbF%5X7e-Dge>T>C$sDj>oFMZ zX(J-0{hyisg0?FnJU={A96Jf_-!gduIIZ__HF<;vAV@VXE^y6`P2aM;Nq^ zrgKo6#srs`c>GKM*X+!=e>{-!{Tz+o{P|`}sD8PkWCtK3$|J!5$j8{Iyo>5z*wrU>C6#O!nOD zO6DJcBjA>;GI0}4BFX_zl}=N;LBQ(_P{cbO2?(#G3*Fb=k8#BqQK-EMe^sFu}Hnb5iWCwtKT?acbNIx0AWFl!fJ z_q5H}dl!}6AHiHBoa*-Uc_*bT+?ph|Udc~dMU`eR`po|{GaIUJFiOs z*S^uiN39EfdMT7OoHSOJ*;^y_gu3iqhV?v)M$0BkHuD^Y4JZaP!Ji9%Z8(mVXb~c3 zAL%7fGo!M%QU1BAzX<*k;@Sph*)9Y18P7onBL@(%FM|up^v7V0XJFSAY zQ~DR@XvpkzxVV!78RsGG=*R=~$O8qoGfoBa=a(owz^hglbT)7Xyik*mmEnOt?tzLI z&J?Bqc(X4lye`B_bfvF1L1w@npE0~7P$@9nzq}@Aht<>*#q^Wq0`iTi;p94qfGdln z%3mv0A_6%x$n73zU|0u7TNV!XdYVw&`#Rh|-ch(dY&uLgKvlkc`~!hp<5bfCl{Gt9 zUbE0PfT_ZD?XO_L5jw8f#EjM-3y$fjTA>shoGJ^7yRcYrl^T9seX_~}VgYWtQ@~CV zWFijPuoBIoU2U|er~7v2d0R*CaW<4st@{OxRD7`=l~3wNBZ_BpW}}L(a#@yJtZVVU zy8nua7{;y2-M|i5&_HLdq^%;CUMswzgVO;_?MZ_im88lcwYbCjd%<7VoP)rP#q$M0 ziY=cwa)Y11Rq~m`D8Wr$F8B%ZaR@%>lafJDSMSzRqBk7**h?Qzu9s~%%i_s8UGppQ zgL8=+N>b%EIGN3_<=1~%$u>*gPxPLg9J0EI@G`9pU4A+A%5`F^fNw?D&qBgon6D%LEiY*p<#o&l+h z;Mnr1rQVl5&v^t_bAbje;Uf zmQ#`X+@^P z$f~+((B~8~%BuqSWzue+Wr&myRO%H$EFai~M^zw&?% z5L;{6=wnsEs8tm`r{c5m(qSM_^9i1Dk$;PhKZ>&;`G53iSyq(QryEp!XiH}3uP0WU z63tok@HkUxcs#LZf>@dIB^)xyUD_Qb3F(fns+UjE>CRD2;QmL|$f%p!db3NIf8hdP z_N*aTuEA?}TgQNbj|WkgHso1XQZd>Q)=#~*f3{i!JKCy&lTAxe$$q8Mu=CFM?Ildd zHUy8OgU1DMVPl=9`V>+YlM|eF@2IKF92U^{k zEcpeAk@hmw6<7&+?3j%XdxDi3+D@28E?O|6)mn85d>M&@HvimYJekw$qql8k$fT>4 zd*g;&320$j2d_AtU_$t1l)##>(e5ZW>XxhY3uZ)S3bW;mX+`JpiQp&PgDkkPLT{SA z;hSE@;5v!s+xceecXC#;h6!&c<41pMU&X%AK1FKVC_Kf}d)E?RXZc)J{oc95a&-Q< zyt*#~v}2O*B6wE`q1eqOyt}T1b$vUpvN8F*Hq_WpEa*y#QTvzs?tl!0L;&jJV)XDb z+Qcr)#y8Z5bp++CCM&jm)z)?spxwPuyM-@VK8J zwbwbA$-fpMeS-3>g&^j2b~jsJaK4ar+1FL2g<6R6bK4O5{JPcUURhxd!j1B`dsIZF zXL?+_NW&>I-L8(9qMzp1EXhAm_Tx43lySZvJ_At^q=pgEv4gSM*+rS zg{qIC#&e@xi1^|Nkd!e@0cU6?|31VYRVW%MzOig`zT4L#OQx#l>e&M(TY(g{#PFSa zQH!~~2g$;7j$V((_b}OJo08++M>$wwQj6-4-6Lp9@UngTiT*`|$%L)gp!tETNXu9$ zQhc9szvV@Z%vOf}i-5@DJK@Om=MIZn+YxI*TXh+(W8F&?nWxIVW@8h4pR}To*@rL6 z62FYyrGZYC`j4gg4n(mVT|ZbL*k(zqF-GCCCLzkdH~XLMCEZ5GoUFySdZI*rn~;p( zRj7n#YYC~VaJF)3)pA+}oEDaC%6NbBcHVk>(s#$hDM|!6BscO)AvMKXsaOF|uwa}R6uwX|qFXwEjdJBm;x$WQj`V&{9rb$ug9s8G<~g((eKol3{>N@_>nK&+krz~iEg_k zwV%59% z6gPV+EL7clXitx-Vwi|8C_RCW!Rh%TJ40(rgR@0ENj-c&1(EqoVtN2`<xzu)Hyw1X|oD|Zuwn3a1<(* z5+q2UpdBxQLlJjXVL1towYNF?yY+!AAwpu0RF?Kt1X(l`DMa8Y7g)MQgWF_7h=lG6 zk_V5DF1ht?mvd$5oniDPtis|^Z%kn9mNYqF-t^^5N2Vo%aAn3R9EW(+b^@lg#A?Ri ztaP?xoFi!{4EhLQW|R|(nbjH*gWIv2b&ntHy+1)NThrjKgH`K-rtc<7&Os=HQr*5U zG3xh5i_!MZkW%2(Q;jb5ZA zz|8qW=LMfn{dTs^!lYO3E?-{lumwIiIdJV+SZ4V&>U7xhex~DPFSQn$meN6L-vwH( z-$*gnmXKbcw}Ln`h)5ZhEX5?Ytdk~4DU4D^N(Io7=18q%-s+^Jz0KudN^^U|)=>p% z-;@q5AW!}oFxOcrZKJy5yg#@CP8;XK)ZQ#q+`23un2WW(W28WxtYx@H<{1 z_J8SIaC>x=tRVU1MTSXR-WxkoW9~@a4nB6R(|)BJp0nmA&|G5Q0aDL)_r3cSIvT5{2Yt-D#PRQRa`o(7IbSu@TkJ z9@b)@xsNt8Kn15(wS>m{hS)_DPK|(_skA|8_tE)JryY(UZ zSQn%XP@&nu+CUZqDpTb4M% zfTj&%CK9vpLHpQmt#|4rk7cS}n*yItwa6^Crsb1?EHKC3{(xm{uY9p5yp>XSy!SG6 z+smXED$;NXWpCLky?zn=FpVG-W2`bOINM`z-45niR%*JjAS6G<;cE)B=QtDF%65p-H;G6kg*0M zgeb~U2>$szy4w|3;R3TBJI=JC_$vPLC+&Q$a&Z-V!#B9fGi6EXdb4Od|D~@geR@d2 z$1(hQiO(hQ!+1gjHZSSW_|^bJJfAQ2e87`xN?pc$Cer6s5$%;J1B!n5BuiAE#*pGt zSbwj-JCJ*m_JJxqP)p-VrY;F!ny`#J(QH4TdmWyrc8#)mUOsw2H&}1M2?AQ@#}+$= z(0mrx{n05%4LRM9+bH+(^xJBustyDO6z^>>c%z-AbuUaPl2>eO?y%yzCunw=j;jOj zA(&$?OvsT3KVP)7+HW2EsC@&zt=uNtww;I{nEoE@!=%nYe2)&R+q$nwEYCKYlb9QC z-JV%}oQwN5;{X#Ga7=&y^KEP_amQhRKJLj7fu}Fxbf;pevFRG;&Ax22Cv&$-Nn!a! zlRh;K&L>B<^6Ap_x~JIqk07+Y92whp`w)l(&|4pfdAF9Fr*ZgJby=cX)O;6?$7GQh zKSvJ=;|gQLis;Twk<@J)99cA~1rQHwN=o$OP7GF`G2o7yJ3J{5?SBho|5+FBK_2>u z>OV;1#?`DkGDaM+ul8@pu|OIQG|!U2s?Mg<83Rn*$)lbJPPfkth#Z9)FUPc7QTj%Q zHB9(1OBaRBYP(zd+X=A*AAeqykY}{CE`?u1e&fP9&T2Ox{fE`+lx^3Q>PJ92e2gs& zK$lgdq3PFouuI2;)en{K52@`FS)vKl5HWZ(fsyYT90FoM{Y|8bV_^fMY2!@)6|#R5 zX|KWm%j{qQb=-g7bdNrMc26+&T<+_J-IPwqX#pDPL~FVdOOd{Uu)@_=ePR4g=LOb! zU;h9Y_3-kZrS+rn(0Y%lc>rABIgqC^Ex=;?5NO&xG{b+IyH^g;q6D&gm{Ym`G3~z` zi<%8g^&hAFrx+8=iM_h1&%jq53>fNVxhbE)5DSqMZ1$Y6xN+w%0`=R3jX{7>H%{hB zt>JR($;;O6Ozz01pFd6&A05}9fmgh4Dl!u^oHL1VGk`+Jin>}B{F*tQx=bAVO`xW& zXWrX?B3+46?XW+Orp-DJ&0hZSlflE)wjxe*d*DrgVu_~FPqS0;jm+i!4-wV2-`8=Gk7SEApgayNSoSUbNbM~N_fiA9y%Fb;N#1r#?K-ts}IOpw{eH47Ry`? z7^-vV=10?xu|U_6KSo^q1K&n&TLDAAU{KyhmG2=I0G=oIRy={)+ZPK{%nMrDc@R+B z<^s&=!tyLgoVT4f#9VM^s3#5&Twp0KT5+EF^2Sll^|;(3`RDd{UXKyJ?+|bYWCMCX zx_wN20;EpDK_4z-bDs0_I`+3s@ylYW-&Sm}bXF#7y>Gn7`#vYRIsfwRgQp;=hE&bV z)@piy_$cNCFJW|B;Xp{hLJa=Bl!2J?Yohcs3p2C-SEh#p1go|=ylfqn5-SR3N&kPY fBLox)Kc(paVVIIDl|g6CfT`~4o9b0T9zOdYx3aI{ diff --git a/plugins/catalog-import/src/components/Buttons/index.tsx b/plugins/catalog-import/src/components/Buttons/index.tsx new file mode 100644 index 0000000000..93dd96b399 --- /dev/null +++ b/plugins/catalog-import/src/components/Buttons/index.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button, CircularProgress } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { ComponentProps } from 'react'; + +const useStyles = makeStyles(theme => ({ + wrapper: { + marginTop: theme.spacing(1), + marginRight: theme.spacing(1), + position: 'relative', + }, + buttonProgress: { + position: 'absolute', + top: '50%', + left: '50%', + marginTop: -12, + marginLeft: -12, + }, + button: { + marginTop: theme.spacing(1), + marginRight: theme.spacing(1), + }, +})); + +export const NextButton = ( + props: ComponentProps & { loading?: boolean }, +) => { + const { loading, ...buttonProps } = props; + const classes = useStyles(); + + return ( +

+ ); +}; + +export const BackButton = (props: ComponentProps) => { + const classes = useStyles(); + + return ( + + ); +}; diff --git a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx deleted file mode 100644 index 9fb170c85c..0000000000 --- a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Entity } from '@backstage/catalog-model'; -import { - errorApiRef, - RouteRef, - StructuredMetadataTable, - useApi, -} from '@backstage/core'; -import { - entityRoute, - entityRouteParams, -} from '@backstage/plugin-catalog-react'; -import { - Button, - CircularProgress, - Divider, - Grid, - Link, - List, - ListItem, - Typography, -} from '@material-ui/core'; -import React, { useCallback, useState } from 'react'; -import { generatePath, resolvePath } from 'react-router'; -import { Link as RouterLink } from 'react-router-dom'; -import * as YAML from 'yaml'; -import { PartialEntity } from '../util/types'; -import { urlType } from '../util/urls'; -import { useGithubRepos } from '../util/useGithubRepos'; -import { ConfigSpec } from './ImportComponentPage'; - -const getEntityCatalogPath = ({ - entity, - catalogRouteRef, -}: { - entity: PartialEntity; - catalogRouteRef: RouteRef; -}) => { - const relativeEntityPathInsideCatalog = generatePath( - entityRoute.path, - entityRouteParams(entity as Entity), - ); - - const resolvedAbsolutePath = resolvePath( - relativeEntityPathInsideCatalog, - catalogRouteRef.path, - )?.pathname; - - return resolvedAbsolutePath; -}; - -type Props = { - nextStep: (options?: { reset: boolean }) => void; - configFile: ConfigSpec; - savePRLink: (PRLink: string) => void; - catalogRouteRef: RouteRef; -}; - -const ComponentConfigDisplay = ({ - nextStep, - configFile, - savePRLink, - catalogRouteRef, -}: Props) => { - const [errorOccurred, setErrorOccurred] = useState(false); - const [submitting, setSubmitting] = useState(false); - const errorApi = useApi(errorApiRef); - const { submitPrToRepo, addLocation } = useGithubRepos(); - const onNext = useCallback(async () => { - try { - setSubmitting(true); - if (urlType(configFile.location) === 'tree') { - const result = await submitPrToRepo(configFile); - savePRLink(result.link); - setSubmitting(false); - nextStep(); - } else { - addLocation(configFile.location); - setSubmitting(false); - nextStep(); - } - } catch (e) { - setErrorOccurred(true); - setSubmitting(false); - errorApi.post(e); - } - }, [submitPrToRepo, configFile, nextStep, savePRLink, errorApi, addLocation]); - - return ( - - {urlType(configFile.location) === 'tree' ? ( - - Following config object will be submitted in a pull request to the - repository{' '} - - {configFile.location} - {' '} - and added as a new location to the backend - - ) : ( - - Following config object will be added as a new location to the backend{' '} - - {configFile.location} - - - )} - - - {urlType(configFile.location) === 'tree' ? ( -
{YAML.stringify(configFile.config)}
- ) : ( - - {configFile.config.map((entity: any, index: number) => { - const entityPath = getEntityCatalogPath({ - entity, - catalogRouteRef, - }); - return ( - - - - {entityPath} - - ), - }} - /> - - {index < configFile.config.length - 1 && ( - - )} - - ); - })} - - )} -
- - {submitting ? ( - - - - ) : null} - - - {errorOccurred ? ( - - ) : null} - - -
- ); -}; - -export default ComponentConfigDisplay; diff --git a/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx b/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx new file mode 100644 index 0000000000..d94a1cad19 --- /dev/null +++ b/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx @@ -0,0 +1,165 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, EntityName } from '@backstage/catalog-model'; +import { + EntityRefLink, + formatEntityRefTitle, +} from '@backstage/plugin-catalog-react'; +import { + Collapse, + IconButton, + List, + ListItem, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import ApartmentIcon from '@material-ui/icons/Apartment'; +import CategoryIcon from '@material-ui/icons/Category'; +import ExpandLessIcon from '@material-ui/icons/ExpandLess'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import ExtensionIcon from '@material-ui/icons/Extension'; +import GroupIcon from '@material-ui/icons/Group'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; +import MemoryIcon from '@material-ui/icons/Memory'; +import PersonIcon from '@material-ui/icons/Person'; +import WorkIcon from '@material-ui/icons/Work'; +import React, { useState } from 'react'; + +const useStyles = makeStyles(theme => ({ + nested: { + paddingLeft: theme.spacing(4), + }, +})); + +function sortEntities(entities: Array) { + return entities.sort((a, b) => + formatEntityRefTitle(a).localeCompare(formatEntityRefTitle(b)), + ); +} + +function getEntityIcon(entity: { kind: string }): React.ReactElement { + switch (entity.kind.toLowerCase()) { + case 'api': + return ; + + case 'component': + return ; + + case 'domain': + return ; + + case 'group': + return ; + + case 'location': + return ; + + case 'system': + return ; + + case 'user': + return ; + + default: + return ; + } +} + +type Props = { + locations: Array<{ target: string; entities: (Entity | EntityName)[] }>; + locationListItemIcon: (target: string) => React.ReactElement; + collapsed?: boolean; + firstListItem?: React.ReactElement; + onItemClick?: (target: string) => void; + withLinks?: boolean; +}; + +export const EntityListComponent = ({ + locations, + collapsed = false, + locationListItemIcon, + onItemClick, + firstListItem, + withLinks = false, +}: Props) => { + const classes = useStyles(); + + const [expandedUrls, setExpandedUrls] = useState([]); + + const handleClick = (url: string) => { + setExpandedUrls(urls => + urls.includes(url) ? urls.filter(u => u !== url) : urls.concat(url), + ); + }; + + return ( + + {firstListItem} + {locations.map(r => ( + + onItemClick?.call(this, r.target)} + > + {locationListItemIcon(r.target)} + + + + {collapsed && ( + + handleClick(r.target)}> + {expandedUrls.includes(r.target) ? ( + + ) : ( + + )} + + + )} + + + + + {sortEntities(r.entities).map(entity => ( + + {getEntityIcon(entity)} + + + ))} + + + + ))} + + ); +}; diff --git a/plugins/catalog-import/src/components/EntityListComponent/index.ts b/plugins/catalog-import/src/components/EntityListComponent/index.ts new file mode 100644 index 0000000000..fc0efc5481 --- /dev/null +++ b/plugins/catalog-import/src/components/EntityListComponent/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { EntityListComponent } from './EntityListComponent'; diff --git a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx deleted file mode 100644 index 38b1065325..0000000000 --- a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { CatalogClient } from '@backstage/catalog-client'; -import { - ApiProvider, - ApiRegistry, - DiscoveryApi, - errorApiRef, -} from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import React from 'react'; -import { catalogImportApiRef, CatalogImportClient } from '../api'; -import { RegisterComponentForm } from './ImportComponentForm'; - -describe('', () => { - let apis: ApiRegistry; - - const mockErrorApi: jest.Mocked = { - post: jest.fn(), - error$: jest.fn(), - }; - - beforeEach(() => { - apis = ApiRegistry.from([ - [catalogApiRef, new CatalogClient({ discoveryApi: {} as DiscoveryApi })], - [ - catalogImportApiRef, - new CatalogImportClient({ - discoveryApi: { getBaseUrl: () => Promise.resolve('base') }, - githubAuthApi: { - getAccessToken: (_, __) => Promise.resolve('token'), - }, - configApi: {} as any, - }), - ], - [errorApiRef, mockErrorApi], - ]); - }); - - async function renderSUT( - nextStep: () => void = () => {}, - saveConfig: () => void = () => {}, - ) { - return await renderInTestApp( - - - , - ); - } - - it('Renders without exploding', async () => { - await renderSUT(); - expect( - screen.getByPlaceholderText('https://github.com/backstage/backstage'), - ).toBeInTheDocument(); - }); - - it('Should have basic URL validation for input', async () => { - await renderSUT(); - await waitFor(() => { - fireEvent.input( - screen.getByPlaceholderText('https://github.com/backstage/backstage'), - { target: { value: 'not a url' } }, - ); - }); - await waitFor(() => { - fireEvent.click(screen.getByText('Next')); - }); - expect(screen.getByText('Must start with https://.')).toBeInTheDocument(); - }); -}); diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx deleted file mode 100644 index b3ec050ea3..0000000000 --- a/plugins/catalog-import/src/components/ImportComponentForm.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { errorApiRef, useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { BackstageTheme } from '@backstage/theme'; -import { - Button, - FormControl, - FormHelperText, - TextField, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import React from 'react'; -import { useForm } from 'react-hook-form'; -import { useMountedState } from 'react-use'; -import { urlType } from '../util/urls'; -import { useGithubRepos } from '../util/useGithubRepos'; -import { ComponentIdValidators } from '../util/validate'; -import { ConfigSpec } from './ImportComponentPage'; - -const useStyles = makeStyles(theme => ({ - form: { - alignItems: 'flex-start', - display: 'flex', - flexFlow: 'column nowrap', - }, - submit: { - marginTop: theme.spacing(1), - }, -})); - -type Props = { - nextStep: () => void; - saveConfig: (configFile: ConfigSpec) => void; - repository: string; -}; - -export const RegisterComponentForm = ({ - nextStep, - saveConfig, - repository, -}: Props) => { - const { register, handleSubmit, errors, formState } = useForm({ - mode: 'onChange', - }); - const classes = useStyles(); - const hasErrors = !!errors.componentLocation; - const dirty = formState?.isDirty; - const catalogApi = useApi(catalogApiRef); - - const isMounted = useMountedState(); - const errorApi = useApi(errorApiRef); - const { - generateEntityDefinitions, - checkForExistingCatalogInfo, - } = useGithubRepos(); - - const onSubmit = async (formData: Record) => { - const { componentLocation: target } = formData; - async function saveCatalogFileConfig(targetString: string) { - const data = await catalogApi.addLocation({ target: targetString }); - saveConfig({ - type: 'file', - location: data.location.target, - config: data.entities, - }); - } - - async function trySaveRepositoryConfig(targetString: string) { - const existingCatalog = await checkForExistingCatalogInfo(targetString); - if (existingCatalog.exists) { - const targetUrl = targetString.endsWith('/') - ? `${targetString}${existingCatalog.url}` - : `${targetString}/${existingCatalog.url}`; - await saveCatalogFileConfig(targetUrl); - } else { - saveConfig({ - type: 'tree', - location: target, - config: await generateEntityDefinitions(target), - }); - } - } - - try { - if (!isMounted()) return; - const type = urlType(target); - if (type === 'tree') { - await trySaveRepositoryConfig(target); - } else { - await saveCatalogFileConfig(target); - } - nextStep(); - } catch (e) { - errorApi.post(e); - } - }; - - return ( -
- - - - {errors.componentLocation && ( - - {errors.componentLocation.message} - - )} - - - -
- ); -}; diff --git a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx index f6ef07e1b2..4cadea85c0 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx @@ -13,254 +13,72 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { CatalogClient } from '@backstage/catalog-client'; import { ApiProvider, ApiRegistry, configApiRef, - errorApiRef, + ConfigReader, } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { msw, renderInTestApp } from '@backstage/test-utils'; -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { act, render } from '@testing-library/react'; import React from 'react'; import { catalogImportApiRef, CatalogImportClient } from '../api'; import { ImportComponentPage } from './ImportComponentPage'; -let codeSearchMockResponse: () => Promise<{ - data: { - total_count: number; - items: Array<{ path: string }>; - }; -}>; - -let findGithubConfigMockResponse = () => ({ - host: 'test.localhost', - owner: 'someuser', -}); - -jest.mock('@backstage/integration', () => ({ - readGitHubIntegrationConfigs: () => ({ - find: findGithubConfigMockResponse, - }), -})); - -jest.mock('@octokit/rest', () => ({ - Octokit: jest.fn().mockImplementation(() => { - return { - repos: { - get: () => - Promise.resolve({ - data: { - default_branch: 'main', - }, - }), - }, - search: { - code: codeSearchMockResponse, - }, - }; - }), -})); - -const OUR_GITHUB_TEST_REPO = 'https://github.com/someuser/somerepo'; describe('', () => { - const server = setupServer(); - msw.setupDefaultHandlers(server); - - beforeEach(() => { - server.use( - rest.post('https://backend.localhost/locations', (_, res, ctx) => { - return res( - ctx.status(201), - ctx.json(require('../mocks/locations-POST-response.json')), - ); - }), - rest.post('https://backend.localhost/analyze-location', (_, res, ctx) => { - return res( - ctx.json(require('../mocks/analyze-location-POST-response.json')), - ); - }), - ); - }); - beforeAll(() => server.listen()); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); + const identityApi = { + getUserId: () => { + return 'user'; + }, + getProfile: () => { + return {}; + }, + getIdToken: () => { + return Promise.resolve('token'); + }, + signOut: () => { + return Promise.resolve(); + }, + }; let apis: ApiRegistry; - const mockErrorApi: jest.Mocked = { - post: jest.fn(), - error$: jest.fn(), - }; - beforeEach(() => { - const getBaseUrl = () => Promise.resolve('https://backend.localhost'); - apis = ApiRegistry.from([ - [ - catalogApiRef, - new CatalogClient({ - discoveryApi: { getBaseUrl }, - }), - ], - [ + apis = ApiRegistry.with( + configApiRef, + new ConfigReader({ integrations: {} }), + ) + .with(catalogApiRef, new CatalogClient({ discoveryApi: {} as any })) + .with( catalogImportApiRef, new CatalogImportClient({ - discoveryApi: { getBaseUrl }, + discoveryApi: {} as any, githubAuthApi: { - getAccessToken: (_, __) => Promise.resolve('token'), + getAccessToken: async () => 'token', }, + identityApi, configApi: {} as any, + catalogApi: {} as any, }), - ], - [ - configApiRef, - { - getOptional: () => 'Title', - getOptionalConfigArray: () => [], - has: () => true, - getConfig: () => ({ - has: () => true, - }), - }, - ], - [errorApiRef, mockErrorApi], - ]); + ); }); - async function renderSUT() { - return await renderInTestApp( - - - , - ); - } - - it('Should not explode on non-Github URLs', async () => { - findGithubConfigMockResponse = () => undefined!!; - await renderSUT(); - await waitFor(() => { - fireEvent.input( - screen.getByPlaceholderText('https://github.com/backstage/backstage'), - { - target: { - value: 'https://test-git-provider.localhost/someuser/somerepo', - }, - }, - ); - }); - - fireEvent.click(screen.getByText('Next')); - await waitFor(() => { - const firstStepInput = screen.queryByPlaceholderText( - 'https://github.com/backstage/backstage', - ); - expect(firstStepInput).not.toBeInTheDocument(); - }); - }); - - it('Should offer direct file import from non-Github URLs', async () => { - findGithubConfigMockResponse = () => undefined!!; - await renderSUT(); - await waitFor(() => { - fireEvent.input( - screen.getByPlaceholderText('https://github.com/backstage/backstage'), - { - target: { - value: - 'https://test-git-provider.localhost/someuser/somerepo/catalog-info.yaml', - }, - }, - ); - }); - - fireEvent.click(screen.getByText('Next')); - await waitFor(() => { - const firstStepInput = screen.queryByPlaceholderText( - 'https://github.com/backstage/backstage', - ); - expect(firstStepInput).not.toBeInTheDocument(); - }); - expect( - screen.getByText( - 'https://test-git-provider.localhost/someuser/somerepo/catalog-info.yaml', - ), - ).toBeInTheDocument(); - }); - - it('Should use found yaml file directly and not create a pull request if GitHub api returns one', async () => { - findGithubConfigMockResponse = () => ({ - host: 'test.localhost', - owner: 'someuser', - }); - codeSearchMockResponse = () => - Promise.resolve({ - data: { - total_count: 3, - items: [ - { path: 'simple/path/catalog-info.yaml' }, - { path: 'co/mple/x/path/catalog-info.yaml' }, - { path: 'catalog-info.yaml' }, - ], - }, - }); - await renderSUT(); - await waitFor(() => { - fireEvent.input( - screen.getByPlaceholderText('https://github.com/backstage/backstage'), - { target: { value: OUR_GITHUB_TEST_REPO } }, - ); - }); - - fireEvent.click(screen.getByText('Next')); - await waitFor(() => { - expect( - screen.getByText( - 'https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml', + it('renders without exploding', async () => { + await act(async () => { + const { getByText } = render( + wrapInTestApp( + + + , ), - ).toBeInTheDocument(); - - const pullReqText = screen.queryByText('pull request'); - expect(pullReqText).not.toBeInTheDocument(); - }); - }); - - it('Should indicate a pull request creation when no yaml file found in the repo', async () => { - findGithubConfigMockResponse = () => ({ - host: 'test.localhost', - owner: 'someuser', - }); - codeSearchMockResponse = () => - Promise.resolve({ - data: { - total_count: 0, - items: [], - }, - }); - const { container } = await renderSUT(); - await waitFor(() => { - fireEvent.input( - screen.getByPlaceholderText('https://github.com/backstage/backstage'), - { target: { value: OUR_GITHUB_TEST_REPO } }, ); - }); - fireEvent.click(screen.getByText('Next')); - await waitFor(() => { - expect(screen.getByText(OUR_GITHUB_TEST_REPO)).toBeInTheDocument(); + expect( + await getByText('Start tracking your component in Backstage'), + ).toBeInTheDocument(); }); - const textNode = container - .querySelector('a[href="https://github.com/someuser/somerepo"]') - ?.closest('p'); - expect(textNode?.innerHTML).toContain( - 'Following config object will be submitted in a pull request to the repository', - ); - expect( - screen.queryByText( - 'https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml', - ), - ).not.toBeInTheDocument(); }); }); diff --git a/plugins/catalog-import/src/components/ImportComponentPage.tsx b/plugins/catalog-import/src/components/ImportComponentPage.tsx index adae137a50..1d1a8056bd 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.tsx @@ -14,35 +14,21 @@ * limitations under the License. */ -import React, { useState } from 'react'; -import { Grid, Typography } from '@material-ui/core'; import { + ConfigApi, + configApiRef, + Content, + ContentHeader, + Header, InfoCard, Page, - Content, - Header, SupportButton, - ContentHeader, - RouteRef, useApi, - configApiRef, - ConfigApi, } from '@backstage/core'; -import { RegisterComponentForm } from './ImportComponentForm'; -import ImportStepper from './ImportStepper'; -import ComponentConfigDisplay from './ComponentConfigDisplay'; -import { ImportFinished } from './ImportFinished'; -import { PartialEntity } from '../util/types'; - -export type ConfigSpec = { - type: 'tree' | 'file'; - location: string; - config: PartialEntity[]; -}; - -function manifestGenerationAvailable(configApi: ConfigApi): boolean { - return configApi.has('integrations.github'); -} +import { Grid, Typography } from '@material-ui/core'; +import React from 'react'; +import { ImportStepper } from './ImportStepper'; +import { StepperProviderOpts } from './ImportStepper/defaults'; function repositories(configApi: ConfigApi): string[] { const integrations = configApi.getConfig('integrations'); @@ -62,26 +48,13 @@ function repositories(configApi: ConfigApi): string[] { return repos; } -export const ImportComponentPage = ({ - catalogRouteRef, -}: { - catalogRouteRef: RouteRef; -}) => { - const [activeStep, setActiveStep] = useState(0); - const [configFile, setConfigFile] = useState({ - type: 'tree', - location: '', - config: [], - }); - const [endLink, setEndLink] = useState(''); - const nextStep = (options?: { reset: boolean }) => { - setActiveStep(step => (options?.reset ? 0 : step + 1)); - }; - +export const ImportComponentPage = (opts: StepperProviderOpts) => { const configApi = useApi(configApiRef); const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const repos = repositories(configApi); const repositoryString = repos.join(', ').replace(/, (\w*)$/, ' or $1'); + return (
@@ -92,9 +65,11 @@ export const ImportComponentPage = ({ software catalog. - - + + + - Ways to register an existing component + Enter the URL to your SCM repository to add it to {appTitle}. - - {manifestGenerationAvailable(configApi) && ( - - GitHub Repo - - If you already have code in a GitHub repository without - Backstage metadata file set up for it, enter the full URL to - your repo and a new pull request with a sample Backstage - metadata Entity File (catalog-info.yaml) will - be opened for you. - - - )} - {repos.length === 1 ? `${repos[0]} ` : ''} Repository & - Entity File + Link to an existing entity file + + + Example:{' '} + + https://github.com/backstage/backstage/blob/master/catalog-info.yaml + - If you've already created a {appTitle} metadata file and put it - in your {repositoryString} repository, you can enter the full - URL to that Entity File. + The wizard analyzes the file, previews the entities, and adds + them to the {appTitle} catalog. + {repos.length > 0 && ( + <> + + Link to a {repositoryString} repository + + + Example: https://github.com/backstage/backstage + + + The wizard discovers all catalog-info.yaml{' '} + files in the repository, previews the entities, and adds + them to the {appTitle} catalog. + + {!opts?.pullRequest?.disable && ( + + If no entities are found, the wizard will prepare a Pull + Request that adds an example{' '} + catalog-info.yaml and prepares the {appTitle}{' '} + catalog to load all entities as soon as the Pull Request + is merged. + + )} + + )} - - - - ), - }, - { - step: 'Review', - content: ( - - ), - }, - { - step: 'Finish', - content: ( - - ), - }, - ]} - activeStep={activeStep} - nextStep={nextStep} - /> - + + + diff --git a/plugins/catalog-import/src/components/ImportFinished.tsx b/plugins/catalog-import/src/components/ImportFinished.tsx deleted file mode 100644 index 9b0f8575a6..0000000000 --- a/plugins/catalog-import/src/components/ImportFinished.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Alert } from '@material-ui/lab'; -import { Button, Grid, Link } from '@material-ui/core'; - -type Props = { - type: 'tree' | 'file'; - nextStep: (options?: { reset: boolean }) => void; - PRLink: string; -}; - -export const ImportFinished = ({ nextStep, PRLink, type }: Props) => { - return ( - - - - {type === 'tree' - ? 'Pull requests have been successfully opened. You can start again to import more repositories' - : 'Entity added to catalog successfully'} - - - - {type === 'tree' ? ( - - View pull request on GitHub - - ) : null} - - - - ); -}; diff --git a/plugins/catalog-import/src/components/ImportStepper.tsx b/plugins/catalog-import/src/components/ImportStepper.tsx deleted file mode 100644 index 03752fa7d4..0000000000 --- a/plugins/catalog-import/src/components/ImportStepper.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import Stepper from '@material-ui/core/Stepper'; -import Step from '@material-ui/core/Step'; -import StepLabel from '@material-ui/core/StepLabel'; -import { StepContent } from '@material-ui/core'; - -type Props = { - steps: { step: string; content: React.ReactNode }[]; - activeStep: number; - nextStep: (options?: { reset: boolean }) => void; -}; - -export default function ImportStepper({ steps, activeStep }: Props) { - return ( - - {steps.map(({ step }) => { - const stepProps: { completed?: boolean } = {}; - return ( - - {step} - {steps[activeStep].content} - - ); - })} - - ); -} diff --git a/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx new file mode 100644 index 0000000000..f92f03c5cb --- /dev/null +++ b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { configApiRef, InfoCard, useApi } from '@backstage/core'; +import { Step, StepContent, Stepper } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { useMemo } from 'react'; +import { ImportFlows, ImportState, useImportState } from '../useImportState'; +import { + defaultGenerateStepper, + defaultStepper, + StepConfiguration, + StepperProvider, + StepperProviderOpts, +} from './defaults'; + +const useStyles = makeStyles(() => ({ + stepperRoot: { + padding: 0, + }, +})); + +type Props = { + initialUrl?: string; + generateStepper?: ( + flow: ImportFlows, + defaults: StepperProvider, + ) => StepperProvider; + variant?: string; + opts?: StepperProviderOpts; +}; + +export const ImportStepper = ({ + initialUrl, + generateStepper = defaultGenerateStepper, + variant, + opts, +}: Props) => { + const configApi = useApi(configApiRef); + const classes = useStyles(); + const state = useImportState({ initialUrl }); + + const states = useMemo( + () => generateStepper(state.activeFlow, defaultStepper), + [generateStepper, state.activeFlow], + ); + + const render = (step: StepConfiguration) => { + return ( + + {step.stepLabel} + {step.content} + + ); + }; + + return ( + + + {render( + states.analyze( + state as Extract, + { apis: { configApi }, opts }, + ), + )} + {render( + states.prepare( + state as Extract, + { apis: { configApi }, opts }, + ), + )} + {render( + states.review( + state as Extract, + { apis: { configApi }, opts }, + ), + )} + {render( + states.finish( + state as Extract, + { apis: { configApi }, opts }, + ), + )} + + + ); +}; diff --git a/plugins/catalog-import/src/components/ImportStepper/defaults.tsx b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx new file mode 100644 index 0000000000..f2dd1d62f4 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx @@ -0,0 +1,293 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigApi } from '@backstage/core'; +import { Box, StepLabel, TextField, Typography } from '@material-ui/core'; +import React from 'react'; +import { BackButton } from '../Buttons'; +import { StepFinishImportLocation } from '../StepFinishImportLocation'; +import { StepInitAnalyzeUrl } from '../StepInitAnalyzeUrl'; +import { + AutocompleteTextField, + StepPrepareCreatePullRequest, +} from '../StepPrepareCreatePullRequest'; +import { StepPrepareSelectLocations } from '../StepPrepareSelectLocations'; +import { StepReviewLocation } from '../StepReviewLocation'; +import { ImportFlows, ImportState } from '../useImportState'; + +export type StepperProviderOpts = { + pullRequest?: { + disable?: boolean; + preparePullRequest?: (apis: StepperApis) => { title: string; body: string }; + }; +}; + +type StepperApis = { + configApi: ConfigApi; +}; + +export type StepConfiguration = { + stepLabel: React.ReactElement; + content: React.ReactElement; +}; + +export type StepperProvider = { + analyze: ( + s: Extract, + opts: { apis: StepperApis; opts?: StepperProviderOpts }, + ) => StepConfiguration; + prepare: ( + s: Extract, + opts: { apis: StepperApis; opts?: StepperProviderOpts }, + ) => StepConfiguration; + review: ( + s: Extract, + opts: { apis: StepperApis; opts?: StepperProviderOpts }, + ) => StepConfiguration; + finish: ( + s: Extract, + opts: { apis: StepperApis; opts?: StepperProviderOpts }, + ) => StepConfiguration; +}; + +function defaultPreparePullRequest(apis: StepperApis) { + const appTitle = apis.configApi.getOptionalString('app.title') ?? 'Backstage'; + const appBaseUrl = apis.configApi.getString('app.baseUrl'); + + return { + title: 'Add catalog-info.yaml config file', + body: `This pull request adds a **Backstage entity metadata file** \ +to this repository so that the component can be added to the \ +[${appTitle} software catalog](${appBaseUrl}).\n\nAfter this pull request is merged, \ +the component will become available.\n\nFor more information, read an \ +[overview of the Backstage software catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview).`, + }; +} + +/** + * The default stepper generation function. + * + * Override this function to customize the import flow. Each flow should at + * least override the prepare operation. + * + * @param flow the name of the active flow + * @param defaults the default steps + */ +export function defaultGenerateStepper( + flow: ImportFlows, + defaults: StepperProvider, +): StepperProvider { + switch (flow) { + // the prepare step is skipped but the label of the step is updated + case 'single-location': + return { + ...defaults, + prepare: () => ({ + stepLabel: ( + + Discovered Locations: 1 + + } + > + Select Locations + + ), + content: <>, + }), + }; + + // let the user select one or more of the discovered locations in the prepare step + case 'multiple-locations': + return { + ...defaults, + prepare: (state, opts) => { + if (state.analyzeResult.type !== 'locations') { + return defaults.prepare(state, opts); + } + + return { + stepLabel: ( + + Discovered Locations: {state.analyzeResult.locations.length} + + } + > + Select Locations + + ), + content: ( + + ), + }; + }, + }; + + case 'no-location': + return { + ...defaults, + prepare: (state, opts) => { + if (state.analyzeResult.type !== 'repository') { + return defaults.prepare(state, opts); + } + + const { title, body } = ( + opts?.opts?.pullRequest?.preparePullRequest ?? + defaultPreparePullRequest + )(opts.apis); + + return { + stepLabel: Create Pull Request, + content: ( + ( + <> + + Pull Request Details + + + + + + + + Entity Configuration + + + + + + + )} + /> + ), + }; + }, + }; + + default: + return defaults; + } +} + +export const defaultStepper: StepperProvider = { + analyze: (state, { opts }) => ({ + stepLabel: Select URL, + content: ( + + ), + }), + + prepare: state => ({ + stepLabel: ( + Optional}> + Import Actions + + ), + content: , + }), + + review: state => ({ + stepLabel: Review, + content: ( + + ), + }), + + finish: state => ({ + stepLabel: Finish, + content: ( + + ), + }), +}; diff --git a/plugins/catalog-import/src/components/ImportStepper/index.ts b/plugins/catalog-import/src/components/ImportStepper/index.ts new file mode 100644 index 0000000000..8d205562a4 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportStepper/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ImportStepper } from './ImportStepper'; +export { defaultGenerateStepper } from './defaults'; diff --git a/plugins/catalog-import/src/components/Router.tsx b/plugins/catalog-import/src/components/Router.tsx index 5175f8f077..fc74c32b30 100644 --- a/plugins/catalog-import/src/components/Router.tsx +++ b/plugins/catalog-import/src/components/Router.tsx @@ -14,15 +14,13 @@ * limitations under the License. */ -import { RouteRef } from '@backstage/core'; import React from 'react'; import { Route, Routes } from 'react-router-dom'; import { ImportComponentPage } from './ImportComponentPage'; +import { StepperProviderOpts } from './ImportStepper/defaults'; -export const Router = ({ catalogRouteRef }: { catalogRouteRef: RouteRef }) => ( +export const Router = (opts: StepperProviderOpts) => ( - } - /> + } /> ); diff --git a/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx b/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx new file mode 100644 index 0000000000..cc5eb9adef --- /dev/null +++ b/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Link } from '@backstage/core'; +import { Grid, Typography } from '@material-ui/core'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; +import React from 'react'; +import { BackButton } from '../Buttons'; +import { EntityListComponent } from '../EntityListComponent'; +import { ReviewResult } from '../useImportState'; + +type Props = { + reviewResult: ReviewResult; + onReset: () => void; +}; + +export const StepFinishImportLocation = ({ reviewResult, onReset }: Props) => ( + <> + {reviewResult.type === 'repository' && ( + <> + + The following Pull Request has been opened:{' '} + + {reviewResult.pullRequest.url} + + + + + Your entities will be imported as soon as the Pull Request is merged. + + + )} + + + The following entities have been added to the catalog: + + + } + withLinks + /> + + + Register another + + +); diff --git a/plugins/catalog-import/src/components/StepFinishImportLocation/index.ts b/plugins/catalog-import/src/components/StepFinishImportLocation/index.ts new file mode 100644 index 0000000000..5f3d9f425c --- /dev/null +++ b/plugins/catalog-import/src/components/StepFinishImportLocation/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { StepFinishImportLocation } from './StepFinishImportLocation'; diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx new file mode 100644 index 0000000000..bfb2434926 --- /dev/null +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx @@ -0,0 +1,401 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { AnalyzeResult, catalogImportApiRef } from '../../api/'; +import { StepInitAnalyzeUrl } from './StepInitAnalyzeUrl'; + +describe('', () => { + const catalogImportApi: jest.Mocked = { + analyzeUrl: jest.fn(), + submitPullRequest: jest.fn(), + }; + + const errorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + + const location = { + target: 'url', + entities: [ + { + kind: 'component', + namespace: 'default', + name: 'name', + }, + ], + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders without exploding', async () => { + const { getByRole } = render( + undefined} />, + { + wrapper: Wrapper, + }, + ); + + expect(getByRole('textbox', { name: /Repository/i })).toBeInTheDocument(); + expect(getByRole('textbox', { name: /Repository/i })).toHaveValue(''); + }); + + it('should use default analysis url', async () => { + const { getByRole } = render( + undefined} + analysisUrl="https://default" + />, + { + wrapper: Wrapper, + }, + ); + + expect(getByRole('textbox', { name: /Repository/i })).toBeInTheDocument(); + expect(getByRole('textbox', { name: /Repository/i })).toHaveValue( + 'https://default', + ); + }); + + it('should not analyze without url', async () => { + const onAnalysisFn = jest.fn(); + + const { getByRole } = render( + , + { + wrapper: Wrapper, + }, + ); + + await act(async () => { + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(catalogImportApi.analyzeUrl).toBeCalledTimes(0); + expect(onAnalysisFn).toBeCalledTimes(0); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should not analyze invalid value', async () => { + const onAnalysisFn = jest.fn(); + + const { getByRole, getByText } = render( + , + { + wrapper: Wrapper, + }, + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'http:/', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(catalogImportApi.analyzeUrl).toBeCalledTimes(0); + expect(onAnalysisFn).toBeCalledTimes(0); + expect(errorApi.post).toBeCalledTimes(0); + expect(getByText('Must start with https://.')).toBeInTheDocument(); + }); + + it('should analyze single location', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'locations', + locations: [location], + } as AnalyzeResult; + + const { getByRole } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(1); + expect(onAnalysisFn.mock.calls[0]).toMatchObject([ + 'single-location', + 'https://my-repository', + analyzeResult, + { prepareResult: analyzeResult }, + ]); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should analyze multiple locations', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'locations', + locations: [location, location], + } as AnalyzeResult; + + const { getByRole } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-1', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(1); + expect(onAnalysisFn.mock.calls[0]).toMatchObject([ + 'multiple-locations', + 'https://my-repository-1', + analyzeResult, + ]); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should not analyze with no locations', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'locations', + locations: [], + } as AnalyzeResult; + + const { getByRole, getByText } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-1', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(0); + expect( + getByText('There are no entities at this location'), + ).toBeInTheDocument(); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should analyze repository', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'repository', + url: 'https://my-repository-2', + integrationType: 'github', + generatedEntities: [ + { + apiVersion: '1', + kind: 'component', + metadata: { + name: 'component-a', + }, + }, + ], + } as AnalyzeResult; + + const { getByRole } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-2', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(1); + expect(onAnalysisFn.mock.calls[0]).toMatchObject([ + 'no-location', + 'https://my-repository-2', + analyzeResult, + ]); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should not analyze repository without entities', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'repository', + url: 'https://my-repository-2', + integrationType: 'github', + generatedEntities: [], + } as AnalyzeResult; + + const { getByRole, getByText } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-2', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(0); + expect( + getByText("Couldn't generate entities for your repository"), + ).toBeInTheDocument(); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should not analyze repository if disabled', async () => { + const onAnalysisFn = jest.fn(); + + const analyzeResult = { + type: 'repository', + url: 'https://my-repository-2', + integrationType: 'github', + generatedEntities: [ + { + apiVersion: '1', + kind: 'component', + metadata: { + name: 'component-a', + }, + }, + ], + } as AnalyzeResult; + + const { getByRole, getByText } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(analyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-2', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(0); + expect( + getByText("Couldn't generate entities for your repository"), + ).toBeInTheDocument(); + expect(errorApi.post).toBeCalledTimes(0); + }); + + it('should report unknown type to the errorapi', async () => { + const onAnalysisFn = jest.fn(); + + const { getByRole, getByText } = render( + , + { + wrapper: Wrapper, + }, + ); + + catalogImportApi.analyzeUrl.mockReturnValueOnce( + Promise.resolve(({ type: 'unknown' } as any) as AnalyzeResult), + ); + + await act(async () => { + await userEvent.type( + getByRole('textbox', { name: /Repository/i }), + 'https://my-repository-2', + ); + userEvent.click(getByRole('button', { name: /Analyze/i })); + }); + + expect(onAnalysisFn).toBeCalledTimes(0); + expect( + getByText( + 'Received unknown analysis result of type unknown. Please contact the support team.', + ), + ).toBeInTheDocument(); + expect(errorApi.post).toBeCalledTimes(1); + expect(errorApi.post.mock.calls[0][0]).toMatchObject( + new Error( + 'Received unknown analysis result of type unknown. Please contact the support team.', + ), + ); + }); +}); diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx new file mode 100644 index 0000000000..a33fe388d8 --- /dev/null +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx @@ -0,0 +1,159 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { errorApiRef, useApi } from '@backstage/core'; +import { FormHelperText, Grid, TextField } from '@material-ui/core'; +import React, { useCallback, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { AnalyzeResult, catalogImportApiRef } from '../../api'; +import { NextButton } from '../Buttons'; +import { ImportFlows, PrepareResult } from '../useImportState'; + +type FormData = { + url: string; +}; + +type Props = { + onAnalysis: ( + flow: ImportFlows, + url: string, + result: AnalyzeResult, + opts?: { prepareResult?: PrepareResult }, + ) => void; + disablePullRequest?: boolean; + analysisUrl?: string; +}; + +/** + * A form that lets the user input a url and analyze it for existing locations or potential entities. + * + * @param onAnalysis is called when the analysis was successful + * @param analysisUrl a url that can be used as a default value + * @param disablePullRequest if true, repositories without entities will abort the wizard + */ +export const StepInitAnalyzeUrl = ({ + onAnalysis, + analysisUrl = '', + disablePullRequest = false, +}: Props) => { + const errorApi = useApi(errorApiRef); + const catalogImportApi = useApi(catalogImportApiRef); + + const { register, handleSubmit, errors, watch } = useForm({ + mode: 'onTouched', + defaultValues: { + url: analysisUrl, + }, + }); + + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(undefined); + + const handleResult = useCallback( + async ({ url }: FormData) => { + setSubmitted(true); + + try { + const analysisResult = await catalogImportApi.analyzeUrl(url); + + switch (analysisResult.type) { + case 'repository': + if ( + !disablePullRequest && + analysisResult.generatedEntities.length > 0 + ) { + onAnalysis('no-location', url, analysisResult); + } else { + setError("Couldn't generate entities for your repository"); + setSubmitted(false); + } + break; + + case 'locations': { + if (analysisResult.locations.length === 1) { + onAnalysis('single-location', url, analysisResult, { + prepareResult: analysisResult, + }); + } else if (analysisResult.locations.length > 1) { + onAnalysis('multiple-locations', url, analysisResult); + } else { + setError('There are no entities at this location'); + setSubmitted(false); + } + break; + } + + default: { + const err = `Received unknown analysis result of type ${ + (analysisResult as any).type + }. Please contact the support team.`; + setError(err); + setSubmitted(false); + + errorApi.post(new Error(err)); + break; + } + } + } catch (e) { + setError(e.message); + setSubmitted(false); + } + }, + [catalogImportApi, disablePullRequest, errorApi, onAnalysis], + ); + + return ( +
+ + (typeof value === 'string' && + value.match(/^https:\/\//) !== null) || + 'Must start with https://.', + }, + })} + required + /> + + {errors.url && ( + {errors.url.message} + )} + + {error && {error}} + + + + Analyze + + + + ); +}; diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts new file mode 100644 index 0000000000..5d36c104c3 --- /dev/null +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { StepInitAnalyzeUrl } from './StepInitAnalyzeUrl'; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx new file mode 100644 index 0000000000..3231c3c6a1 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CircularProgress, TextField } from '@material-ui/core'; +import { TextFieldProps } from '@material-ui/core/TextField/TextField'; +import { Autocomplete } from '@material-ui/lab'; +import React from 'react'; +import { + Control, + Controller, + FieldErrors, + ValidationRules, +} from 'react-hook-form'; + +type Props = { + name: TFieldValue; + options: string[]; + required?: boolean; + + control?: Control>; + errors?: FieldErrors>; + rules?: ValidationRules; + + loading?: boolean; + loadingText?: string; + + helperText?: React.ReactNode; + errorHelperText?: string; + + textFieldProps?: Omit; +}; + +export const AutocompleteTextField = ({ + name, + options, + required, + control, + errors, + rules, + loading = false, + loadingText, + helperText, + errorHelperText, + textFieldProps = {}, +}: Props) => { + return ( + ( + onChange(v || '')} + onBlur={onBlur} + value={value} + autoSelect + freeSolo + renderInput={params => ( + + {loading ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }} + {...textFieldProps} + /> + )} + /> + )} + /> + ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx new file mode 100644 index 0000000000..0bf7746e6a --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx @@ -0,0 +1,113 @@ +/* + * 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 { FormHelperText, TextField } from '@material-ui/core'; +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { PreparePullRequestForm } from './PreparePullRequestForm'; + +describe('', () => { + it('renders without exploding', async () => { + const onSubmitFn = jest.fn(); + + const { getByRole } = render( + ( + <> + + {' '} + + )} + onSubmit={onSubmitFn} + />, + ); + + await act(async () => { + userEvent.click(getByRole('button', { name: /submit/i })); + }); + + expect(onSubmitFn).toBeCalledTimes(1); + expect(onSubmitFn.mock.calls[0][0]).toMatchObject({ main: 'default' }); + }); + + it('should register a text field', async () => { + const onSubmitFn = jest.fn(); + + const { getByRole, getByLabelText } = render( + ( + <> + + + + )} + onSubmit={onSubmitFn} + />, + ); + + await act(async () => { + userEvent.clear(getByLabelText('Main Field')); + await userEvent.type(getByLabelText('Main Field'), 'My Text'); + userEvent.click(getByRole('button', { name: /submit/i })); + }); + + expect(onSubmitFn).toBeCalledTimes(1); + expect(onSubmitFn.mock.calls[0][0]).toMatchObject({ main: 'My Text' }); + }); + + it('registers required attribute', async () => { + const onSubmitFn = jest.fn(); + + const { queryByText, getByRole } = render( + ( + <> + + {errors.main && ( + + Error in required main field + + )} + {' '} + + )} + onSubmit={onSubmitFn} + />, + ); + + expect(queryByText('Error in required main field')).not.toBeInTheDocument(); + + await act(async () => { + userEvent.click(getByRole('button', { name: /submit/i })); + }); + + expect(onSubmitFn).not.toBeCalled(); + expect(queryByText('Error in required main field')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx new file mode 100644 index 0000000000..9d59f4e10e --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + SubmitHandler, + UnpackNestedValue, + useForm, + UseFormMethods, + UseFormOptions, +} from 'react-hook-form'; + +type Props> = Pick< + UseFormOptions, + 'defaultValues' +> & { + onSubmit: SubmitHandler; + + render: ( + props: Pick< + UseFormMethods, + 'errors' | 'register' | 'control' + > & { + values: UnpackNestedValue; + }, + ) => React.ReactNode; +}; + +/** + * A form wrapper that creates a form that is used to prepare a pull request. It + * hosts the form logic. + * + * @param defaultValues the default values of the form + * @param onSubmit a callback that is executed when the form is submitted + * (initiated by a button of type="submit") + * @param render render the form elements + */ +export const PreparePullRequestForm = < + TFieldValues extends Record +>({ + defaultValues, + onSubmit, + render, +}: Props) => { + const { handleSubmit, watch, control, register, errors } = useForm< + TFieldValues + >({ mode: 'onTouched', defaultValues }); + + return ( +
+ {render({ values: watch(), errors, register, control })} +
+ ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.test.tsx new file mode 100644 index 0000000000..5bc22dbd42 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { makeStyles } from '@material-ui/core'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { PreviewCatalogInfoComponent } from './PreviewCatalogInfoComponent'; + +const useStyles = makeStyles({ + displayNone: { + display: 'none', + }, +}); + +const entities: Entity[] = [ + { + apiVersion: '1', + kind: 'Kind', + metadata: { + name: 'name', + }, + }, + { + apiVersion: '1', + kind: 'Kind_2', + metadata: { + name: 'name', + }, + }, +]; + +describe('', () => { + it('renders without exploding', async () => { + const { getByText } = render( + , + ); + + const repositoryUrl = getByText('http://my-repository/a/catalog-info.yaml'); + const kindText = getByText('Kind_2'); + expect(repositoryUrl).toBeInTheDocument(); + expect(repositoryUrl).toBeVisible(); + expect(kindText).toBeInTheDocument(); + expect(kindText).toBeVisible(); + }); + + it('renders card with custom styles', async () => { + const { result } = renderHook(() => useStyles()); + + const { getByText } = render( + , + ); + + const repositoryUrl = getByText('http://my-repository/a/catalog-info.yaml'); + const kindText = getByText('Kind_2'); + expect(repositoryUrl).toBeInTheDocument(); + expect(repositoryUrl).not.toBeVisible(); + expect(kindText).toBeInTheDocument(); + expect(kindText).not.toBeVisible(); + }); + + it('renders with custom styles', async () => { + const { result } = renderHook(() => useStyles()); + + const { getByText } = render( + , + ); + + const repositoryUrl = getByText('http://my-repository/a/catalog-info.yaml'); + const kindText = getByText('Kind_2'); + expect(repositoryUrl).toBeInTheDocument(); + expect(repositoryUrl).toBeVisible(); + expect(kindText).toBeInTheDocument(); + expect(kindText).not.toBeVisible(); + }); +}); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx new file mode 100644 index 0000000000..c10c011820 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { CodeSnippet } from '@backstage/core'; +import { Card, CardContent, CardHeader } from '@material-ui/core'; +import React from 'react'; +import YAML from 'yaml'; + +type Props = { + repositoryUrl: string; + entities: Entity[]; + classes?: { card?: string; cardContent?: string }; +}; + +export const PreviewCatalogInfoComponent = ({ + repositoryUrl, + entities, + classes, +}: Props) => { + return ( + + {`${repositoryUrl.replace( + /[\/]*$/, + '', + )}/catalog-info.yaml`} + } + /> + + + YAML.stringify(e)) + .join('---\n') + .trim()} + language="yaml" + /> + + + ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx new file mode 100644 index 0000000000..1556bd9b41 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx @@ -0,0 +1,83 @@ +/* + * 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 { makeStyles } from '@material-ui/core'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { PreviewPullRequestComponent } from './PreviewPullRequestComponent'; + +const useStyles = makeStyles({ + displayNone: { + display: 'none', + }, +}); + +describe('', () => { + it('renders without exploding', async () => { + const { getByText } = render( + , + ); + + const title = getByText('My Title'); + const description = getByText('description', { selector: 'strong' }); + expect(title).toBeInTheDocument(); + expect(title).toBeVisible(); + expect(description).toBeInTheDocument(); + expect(description).toBeVisible(); + }); + + it('renders card with custom styles', async () => { + const { result } = renderHook(() => useStyles()); + + const { getByText } = render( + , + ); + + const title = getByText('My Title'); + const description = getByText('description', { selector: 'strong' }); + expect(title).toBeInTheDocument(); + expect(title).not.toBeVisible(); + expect(description).toBeInTheDocument(); + expect(description).not.toBeVisible(); + }); + + it('renders with custom styles', async () => { + const { result } = renderHook(() => useStyles()); + + const { getByText } = render( + , + ); + + const title = getByText('My Title'); + const description = getByText('description', { selector: 'strong' }); + expect(title).toBeInTheDocument(); + expect(title).toBeVisible(); + expect(description).toBeInTheDocument(); + expect(description).not.toBeVisible(); + }); +}); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx new file mode 100644 index 0000000000..01af05d43a --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MarkdownContent } from '@backstage/core'; +import { Card, CardContent, CardHeader } from '@material-ui/core'; +import React from 'react'; + +type Props = { + title: string; + description: string; + classes?: { card?: string; cardContent?: string }; +}; + +export const PreviewPullRequestComponent = ({ + title, + description, + classes, +}: Props) => { + return ( + + + + + + + ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx new file mode 100644 index 0000000000..0b38fdaf58 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -0,0 +1,287 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { TextField } from '@material-ui/core'; +import { act, render, RenderResult } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { AnalyzeResult, catalogImportApiRef } from '../../api'; +import { StepPrepareCreatePullRequest } from './StepPrepareCreatePullRequest'; + +describe('', () => { + const catalogImportApi: jest.Mocked = { + analyzeUrl: jest.fn(), + submitPullRequest: jest.fn(), + }; + + const catalogApi: jest.Mocked = { + getEntities: jest.fn(), + addLocation: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + + const onPrepareFn = jest.fn(); + const analyzeResult = { + type: 'repository', + url: 'https://my-repository', + integrationType: 'github', + generatedEntities: [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'my-component', + }, + spec: { + owner: 'my-owner', + }, + }, + ], + } as Extract; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders without exploding', async () => { + catalogApi.getEntities.mockReturnValue(Promise.resolve({ items: [] })); + + await act(async () => { + const { getByText } = render( + { + return ( + <> + + + + + + ); + }} + />, + { + wrapper: Wrapper, + }, + ); + + const title = getByText('My title'); + const description = getByText('body', { selector: 'strong' }); + expect(title).toBeInTheDocument(); + expect(title).toBeVisible(); + expect(description).toBeInTheDocument(); + expect(description).toBeVisible(); + }); + }); + + it('should submit created PR', async () => { + catalogApi.getEntities.mockReturnValue(Promise.resolve({ items: [] })); + catalogImportApi.submitPullRequest.mockReturnValue( + Promise.resolve({ + location: 'https://my/location.yaml', + link: 'https://my/pull', + }), + ); + + let result: RenderResult; + await act(async () => { + result = await render( + { + return ( + <> + + + + + + ); + }} + />, + { + wrapper: Wrapper, + }, + ); + + await userEvent.type(await result.getByLabelText('name'), '-changed'); + await userEvent.type(await result.getByLabelText('owner'), '-changed'); + await userEvent.click( + await result.getByRole('button', { name: /Create PR/i }), + ); + }); + + expect(catalogImportApi.submitPullRequest).toBeCalledTimes(1); + expect(catalogImportApi.submitPullRequest.mock.calls[0]).toMatchObject([ + { + body: 'My **body**', + fileContent: `apiVersion: "1" +kind: Component +metadata: + name: my-component-changed + namespace: default +spec: + owner: my-owner-changed +`, + repositoryUrl: 'https://my-repository', + title: 'My title', + }, + ]); + expect(onPrepareFn).toBeCalledTimes(1); + expect(onPrepareFn.mock.calls[0]).toMatchObject([ + { + type: 'repository', + url: 'https://my-repository', + integrationType: 'github', + pullRequest: { + url: 'https://my/pull', + }, + locations: [ + { + entities: [ + { + kind: 'Component', + name: 'my-component-changed', + namespace: 'default', + }, + ], + target: 'https://my/location.yaml', + }, + ], + }, + { + notRepeatable: true, + }, + ]); + }); + + it('should show error message', async () => { + catalogApi.getEntities.mockResolvedValueOnce({ items: [] }); + catalogImportApi.submitPullRequest.mockRejectedValueOnce( + new Error('some error'), + ); + + let result: RenderResult; + await act(async () => { + result = await render( + { + return ( + <> + + + + + + ); + }} + />, + { + wrapper: Wrapper, + }, + ); + + await userEvent.click( + await result.getByRole('button', { name: /Create PR/i }), + ); + }); + + expect(result!.getByText('some error')).toBeInTheDocument(); + expect(catalogImportApi.submitPullRequest).toBeCalledTimes(1); + expect(onPrepareFn).toBeCalledTimes(0); + }); + + it('should load groups', async () => { + const renderFormFieldsFn = jest.fn(); + catalogApi.getEntities.mockReturnValue( + Promise.resolve({ + items: [ + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'my-group', + }, + }, + ], + }), + ); + + await act(async () => { + await render( + , + { + wrapper: Wrapper, + }, + ); + }); + + expect(catalogApi.getEntities).toBeCalledTimes(1); + expect(renderFormFieldsFn).toBeCalled(); + expect(renderFormFieldsFn.mock.calls[0][0]).toMatchObject({ + groups: [], + groupsLoading: true, + }); + expect( + renderFormFieldsFn.mock.calls[ + renderFormFieldsFn.mock.calls.length - 1 + ][0], + ).toMatchObject({ groups: ['Group:my-group'], groupsLoading: false }); + }); +}); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx new file mode 100644 index 0000000000..d9e7b962ab --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx @@ -0,0 +1,249 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, serializeEntityRef } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Box, FormHelperText, Grid, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { useCallback, useState } from 'react'; +import { UseFormMethods } from 'react-hook-form'; +import { useAsync } from 'react-use'; +import YAML from 'yaml'; +import { AnalyzeResult, catalogImportApiRef } from '../../api'; +import { PartialEntity } from '../../types'; +import { BackButton, NextButton } from '../Buttons'; +import { PrepareResult } from '../useImportState'; +import { PreparePullRequestForm } from './PreparePullRequestForm'; +import { PreviewCatalogInfoComponent } from './PreviewCatalogInfoComponent'; +import { PreviewPullRequestComponent } from './PreviewPullRequestComponent'; + +const useStyles = makeStyles(theme => ({ + previewCard: { + marginTop: theme.spacing(1), + }, + previewCardContent: { + paddingTop: 0, + }, +})); + +type FormData = { + title: string; + body: string; + componentName: string; + owner: string; +}; + +type Props = { + analyzeResult: Extract; + onPrepare: ( + result: PrepareResult, + opts?: { notRepeatable?: boolean }, + ) => void; + onGoBack?: () => void; + + defaultTitle: string; + defaultBody: string; + + renderFormFields: ( + props: Pick, 'errors' | 'register' | 'control'> & { + groups: string[]; + groupsLoading: boolean; + }, + ) => React.ReactNode; +}; + +function generateEntities( + entities: PartialEntity[], + componentName: string, + owner: string, +): Entity[] { + return entities.map(e => ({ + ...e, + apiVersion: e.apiVersion!, + kind: e.kind!, + metadata: { + ...e.metadata, + name: componentName, + namespace: e.metadata?.namespace ?? 'default', + }, + spec: { + ...e.spec, + owner, + }, + })); +} + +export const StepPrepareCreatePullRequest = ({ + analyzeResult, + onPrepare, + onGoBack, + renderFormFields, + defaultTitle, + defaultBody, +}: Props) => { + const classes = useStyles(); + const catalogApi = useApi(catalogApiRef); + const catalogInfoApi = useApi(catalogImportApiRef); + + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(); + + const { loading: groupsLoading, value: groups } = useAsync(async () => { + const groupEntities = await catalogApi.getEntities({ + filter: { kind: 'group' }, + }); + + // TODO: defaultKind (=group), defaultNamespace (=same as entity) + return groupEntities.items.map(e => serializeEntityRef(e) as string).sort(); + }); + + const handleResult = useCallback( + async (data: FormData) => { + setSubmitted(true); + + try { + const pr = await catalogInfoApi.submitPullRequest({ + repositoryUrl: analyzeResult.url, + title: data.title, + body: data.body, + fileContent: generateEntities( + analyzeResult.generatedEntities, + data.componentName, + data.owner, + ) + .map(e => YAML.stringify(e)) + .join('---\n'), + }); + + onPrepare( + { + type: 'repository', + url: analyzeResult.url, + integrationType: analyzeResult.integrationType, + pullRequest: { + url: pr.link, + }, + locations: [ + { + target: pr.location, + entities: generateEntities( + analyzeResult.generatedEntities, + data.componentName, + data.owner, + ).map(e => ({ + kind: e.kind, + namespace: e.metadata.namespace!, + name: e.metadata.name, + })), + }, + ], + }, + { notRepeatable: true }, + ); + } catch (e) { + setError(e.message); + setSubmitted(false); + } + }, + [ + analyzeResult.generatedEntities, + analyzeResult.integrationType, + analyzeResult.url, + catalogInfoApi, + onPrepare, + ], + ); + + return ( + <> + + You entered a link to a {analyzeResult.integrationType} repository but + we didn't found a catalog-info.yaml. Use this form to + create a Pull Request that creates an initial{' '} + catalog-info.yaml. + + + + onSubmit={handleResult} + defaultValues={{ + title: defaultTitle, + body: defaultBody, + owner: + (analyzeResult.generatedEntities[0]?.spec?.owner as string) || '', + componentName: + analyzeResult.generatedEntities[0]?.metadata?.name || '', + }} + render={({ values, errors, control, register }) => ( + <> + {renderFormFields({ + errors, + register, + control, + groups: groups ?? [], + groupsLoading, + })} + + + Preview Pull Request + + + + + + Preview Entities + + + + + {error && {error}} + + + {onGoBack && ( + + )} + + Create PR + + + + )} + /> + + ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts new file mode 100644 index 0000000000..119feee4a1 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AutocompleteTextField } from './AutocompleteTextField'; +export { PreparePullRequestForm } from './PreparePullRequestForm'; +export { PreviewCatalogInfoComponent } from './PreviewCatalogInfoComponent'; +export { PreviewPullRequestComponent } from './PreviewPullRequestComponent'; +export { StepPrepareCreatePullRequest } from './StepPrepareCreatePullRequest'; diff --git a/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx new file mode 100644 index 0000000000..82da9b9572 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx @@ -0,0 +1,202 @@ +/* + * 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 { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { AnalyzeResult } from '../../api'; +import { StepPrepareSelectLocations } from './StepPrepareSelectLocations'; + +describe('', () => { + const analyzeResult = { + type: 'locations', + locations: [ + { + target: 'url', + entities: [ + { + kind: 'component', + namespace: 'default', + name: 'name', + }, + ], + }, + { + target: 'url-2', + entities: [ + { + kind: 'component', + namespace: 'default', + name: 'name', + }, + { + kind: 'api', + namespace: 'default', + name: 'name', + }, + ], + }, + ], + } as Extract; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders without exploding', async () => { + const { getByRole } = render( + undefined} + onGoBack={() => undefined} + />, + ); + + expect(getByRole('button', { name: /Review/i })).toBeDisabled(); + }); + + it('should select and deselect all', async () => { + const { getByRole, getAllByRole } = render( + undefined} + onGoBack={() => undefined} + />, + ); + + const checkboxes = getAllByRole('checkbox'); + checkboxes.forEach(c => expect(c).not.toBeChecked()); + expect(getByRole('button', { name: /Review/i })).toBeDisabled(); + + await act(async () => { + userEvent.click(getByRole('button', { name: /Select All/i })); + }); + + checkboxes.forEach(c => expect(c).toBeChecked()); + expect(getByRole('button', { name: /Review/i })).not.toBeDisabled(); + + await act(async () => { + userEvent.click(getByRole('button', { name: /Select All/i })); + }); + + checkboxes.forEach(c => expect(c).not.toBeChecked()); + expect(getByRole('button', { name: /Review/i })).toBeDisabled(); + }); + + it('should preselect prepared locations', async () => { + const { getAllByRole } = render( + undefined} + onGoBack={() => undefined} + />, + ); + + const checkboxes = getAllByRole('checkbox'); + + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + }); + + it('should select items', async () => { + const { getAllByRole } = render( + undefined} + onGoBack={() => undefined} + />, + ); + + const checkboxes = getAllByRole('checkbox'); + checkboxes.forEach(c => expect(c).not.toBeChecked()); + + await act(async () => { + userEvent.click(checkboxes[1]); + }); + + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + + await act(async () => { + userEvent.click(checkboxes[1]); + }); + + checkboxes.forEach(c => expect(c).not.toBeChecked()); + }); + + it('should go back', async () => { + const onGoBack = jest.fn(); + + const { getByRole } = render( + undefined} + onGoBack={onGoBack} + />, + ); + + await act(async () => { + userEvent.click(getByRole('button', { name: /Back/i })); + }); + + expect(onGoBack).toBeCalledTimes(1); + }); + + it('should submit', async () => { + const onPrepare = jest.fn(); + + const { getAllByRole, getByRole } = render( + undefined} + />, + ); + + const checkboxes = getAllByRole('checkbox'); + + await act(async () => { + userEvent.click(checkboxes[1]); + }); + + await act(async () => { + userEvent.click(getByRole('button', { name: /Review/i })); + }); + + expect(onPrepare).toBeCalledTimes(1); + expect(onPrepare.mock.calls[0][0]).toMatchObject({ + type: 'locations', + locations: [ + { + target: 'url', + entities: [ + { + kind: 'component', + namespace: 'default', + name: 'name', + }, + ], + }, + ], + } as Extract); + }); +}); diff --git a/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.tsx b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.tsx new file mode 100644 index 0000000000..d44472fb5a --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.tsx @@ -0,0 +1,124 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Checkbox, + Grid, + ListItem, + ListItemIcon, + ListItemText, + Typography, +} from '@material-ui/core'; +import React, { useCallback, useState } from 'react'; +import { AnalyzeResult } from '../../api'; +import { BackButton, NextButton } from '../Buttons'; +import { EntityListComponent } from '../EntityListComponent'; +import { PrepareResult } from '../useImportState'; + +type Props = { + analyzeResult: Extract; + prepareResult?: PrepareResult; + onPrepare: (result: PrepareResult) => void; + onGoBack?: () => void; +}; + +/** + * A form that lets a user select one of a list of locations to import + * + * @param analyzeResult the result of the analysis + * @param prepareResult the selectected locations from a previous step + * @param onPrepare called after the selection + * @param onGoBack called to go back to the previous step + */ +export const StepPrepareSelectLocations = ({ + analyzeResult, + prepareResult, + onPrepare, + onGoBack, +}: Props) => { + const [selectedUrls, setSelectedUrls] = useState( + prepareResult?.locations.map(l => l.target) || [], + ); + + const handleResult = useCallback(async () => { + onPrepare({ + type: 'locations', + locations: analyzeResult.locations.filter((l: any) => + selectedUrls.includes(l.target), + ), + }); + }, [analyzeResult.locations, onPrepare, selectedUrls]); + + const onItemClick = (url: string) => { + setSelectedUrls(urls => + urls.includes(url) ? urls.filter(u => u !== url) : urls.concat(url), + ); + }; + + const onSelectAll = () => { + setSelectedUrls(urls => + urls.length < analyzeResult.locations.length + ? analyzeResult.locations.map(l => l.target) + : [], + ); + }; + + return ( + <> + + Select one or more locations that are present in your git repository: + + + + + 0 && + selectedUrls.length < analyzeResult.locations.length + } + tabIndex={-1} + disableRipple + /> + + + + } + onItemClick={onItemClick} + locations={analyzeResult.locations} + locationListItemIcon={target => ( + + )} + collapsed + /> + + + {onGoBack && } + + Review + + + + ); +}; diff --git a/plugins/catalog-import/src/components/StepPrepareSelectLocations/index.ts b/plugins/catalog-import/src/components/StepPrepareSelectLocations/index.ts new file mode 100644 index 0000000000..db98a63be0 --- /dev/null +++ b/plugins/catalog-import/src/components/StepPrepareSelectLocations/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { StepPrepareSelectLocations } from './StepPrepareSelectLocations'; diff --git a/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx new file mode 100644 index 0000000000..eb2268f212 --- /dev/null +++ b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx @@ -0,0 +1,134 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { configApiRef, Link, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { FormHelperText, Grid, Typography } from '@material-ui/core'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; +import React, { useCallback, useState } from 'react'; +import { BackButton, NextButton } from '../Buttons'; +import { EntityListComponent } from '../EntityListComponent'; +import { PrepareResult, ReviewResult } from '../useImportState'; + +type Props = { + prepareResult: PrepareResult; + onReview: (result: ReviewResult) => void; + onGoBack?: () => void; +}; + +export const StepReviewLocation = ({ + prepareResult, + onReview, + onGoBack, +}: Props) => { + const catalogApi = useApi(catalogApiRef); + const configApi = useApi(configApiRef); + + const appTitle = configApi.getOptional('app.title') || 'Backstage'; + + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(); + + const handleImport = useCallback(async () => { + setSubmitted(true); + try { + const result = await Promise.all( + prepareResult.locations.map(l => + catalogApi.addLocation({ + type: 'url', + target: l.target, + presence: + prepareResult.type === 'repository' ? 'optional' : 'required', + }), + ), + ); + + onReview({ + ...prepareResult, + locations: result.map(r => ({ + target: r.location.target, + entities: r.entities, + })), + }); + } catch (e) { + // TODO: this error should be handled differently. We add it as 'optional' and + // it is not uncommon that a PR has not been merged yet. + if ( + prepareResult.type === 'repository' && + e.message.startsWith( + 'Location was added but has no entities specified yet', + ) + ) { + onReview({ + ...prepareResult, + locations: prepareResult.locations.map(l => ({ + target: l.target, + entities: [], + })), + }); + } else { + setError(e.message); + setSubmitted(false); + } + } + }, [prepareResult, onReview, catalogApi]); + + return ( + <> + {prepareResult.type === 'repository' && ( + <> + + The following Pull Request has been opened:{' '} + + {prepareResult.pullRequest.url} + + + + + You can already import the location and {appTitle} will fetch the + entities as soon as the Pull Request is merged. + + + )} + + + The following entities will be added to the catalog: + + + } + /> + + {error && {error}} + + + {onGoBack && } + handleImport()} + > + Import + + + + ); +}; diff --git a/plugins/catalog-import/src/components/StepReviewLocation/index.ts b/plugins/catalog-import/src/components/StepReviewLocation/index.ts new file mode 100644 index 0000000000..2c93e98437 --- /dev/null +++ b/plugins/catalog-import/src/components/StepReviewLocation/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { StepReviewLocation } from './StepReviewLocation'; diff --git a/plugins/catalog-import/src/components/index.ts b/plugins/catalog-import/src/components/index.ts new file mode 100644 index 0000000000..180752c873 --- /dev/null +++ b/plugins/catalog-import/src/components/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './ImportStepper'; +export * from './EntityListComponent'; +export * from './StepInitAnalyzeUrl'; +export * from './StepPrepareCreatePullRequest'; diff --git a/plugins/catalog-import/src/components/useImportState.test.tsx b/plugins/catalog-import/src/components/useImportState.test.tsx new file mode 100644 index 0000000000..d1ef3184ad --- /dev/null +++ b/plugins/catalog-import/src/components/useImportState.test.tsx @@ -0,0 +1,372 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, EntityName } from '@backstage/catalog-model'; +import { cleanup } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { AnalyzeResult } from '../api'; + +import { + ImportState, + PrepareResult, + ReviewResult, + useImportState, +} from './useImportState'; + +describe('useImportState', () => { + const as = ( + curr: ImportState, + _: T, + ) => curr as Extract; + + const locationAP: AnalyzeResult & PrepareResult = { + type: 'locations', + locations: [ + { + target: 'https://0', + entities: [] as EntityName[], + }, + ], + }; + + const locationR: ReviewResult = { + type: 'locations', + locations: [ + { + target: 'https://0', + entities: [] as Entity[], + }, + ], + }; + + it('should use initial url', async () => { + const { result } = renderHook(() => + useImportState({ initialUrl: 'http://my-url' }), + ); + await cleanup(); + + expect(result.current).toMatchObject({ + activeFlow: 'unknown', + activeStepNumber: 0, + analysisUrl: 'http://my-url', + activeState: 'analyze', + analyzeResult: undefined, + prepareResult: undefined, + reviewResult: undefined, + }); + }); + + describe('onAnalysis & onPrepare & onReview & onReset', () => { + it('should work', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current).toMatchObject({ + activeFlow: 'unknown', + activeStepNumber: 0, + analysisUrl: undefined, + activeState: 'analyze', + analyzeResult: undefined, + prepareResult: undefined, + reviewResult: undefined, + }); + + act(() => { + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + ); + }); + + expect(result.current).toMatchObject({ + activeFlow: 'single-location', + activeStepNumber: 1, + analysisUrl: 'http://my-url', + activeState: 'prepare', + analyzeResult: locationAP, + prepareResult: undefined, + reviewResult: undefined, + }); + + act(() => { + as(result.current, 'prepare').onPrepare(locationAP); + }); + + expect(result.current).toMatchObject({ + activeFlow: 'single-location', + activeStepNumber: 2, + analysisUrl: 'http://my-url', + activeState: 'review', + analyzeResult: locationAP, + prepareResult: locationAP, + reviewResult: undefined, + }); + + act(() => { + as(result.current, 'review').onReview(locationR); + }); + + expect(result.current).toMatchObject({ + activeFlow: 'single-location', + activeStepNumber: 3, + analysisUrl: 'http://my-url', + activeState: 'finish', + analyzeResult: locationAP, + prepareResult: locationAP, + reviewResult: locationR, + }); + + act(() => result.current.onReset()); + + expect(result.current).toMatchObject({ + activeFlow: 'unknown', + activeStepNumber: 0, + analysisUrl: undefined, + activeState: 'analyze', + analyzeResult: undefined, + prepareResult: undefined, + reviewResult: locationR, + }); + }); + + it('should work skipped', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current).toMatchObject({ + activeFlow: 'unknown', + activeStepNumber: 0, + analysisUrl: undefined, + activeState: 'analyze', + analyzeResult: undefined, + prepareResult: undefined, + reviewResult: undefined, + }); + + act(() => { + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + { + prepareResult: locationAP, + }, + ); + }); + + expect(result.current).toMatchObject({ + activeFlow: 'single-location', + activeStepNumber: 2, + analysisUrl: 'http://my-url', + activeState: 'review', + analyzeResult: locationAP, + prepareResult: locationAP, + reviewResult: undefined, + }); + + act(() => { + as(result.current, 'review').onReview(locationR); + }); + + expect(result.current).toMatchObject({ + activeFlow: 'single-location', + activeStepNumber: 3, + analysisUrl: 'http://my-url', + activeState: 'finish', + analyzeResult: locationAP, + prepareResult: locationAP, + reviewResult: locationR, + }); + }); + + it('should ignore on invalid state', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + // state 'analyze' + act(() => { + as(result.current, 'prepare').onPrepare(locationAP); + as(result.current, 'review').onReview(locationR); + }); + + expect(result.current.activeState).toBe('analyze'); + expect(result.current.activeFlow).toBe('unknown'); + + // switch state to 'prepare' + act(() => + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + ), + ); + + // state 'prepare' + act(() => { + as(result.current, 'analyze').onAnalysis( + 'multiple-locations', + 'http://my-url', + locationAP, + ); + as(result.current, 'review').onReview(locationR); + }); + + expect(result.current.activeState).toBe('prepare'); + expect(result.current.activeFlow).toBe('single-location'); + + // switch to 'review' + act(() => as(result.current, 'prepare').onPrepare(locationAP)); + + // state 'review' + act(() => { + as(result.current, 'analyze').onAnalysis( + 'multiple-locations', + 'http://my-url', + locationAP, + ); + as(result.current, 'prepare').onPrepare({ + type: 'locations', + locations: [], + }); + }); + + expect(result.current.activeState).toBe('review'); + expect(result.current.activeFlow).toBe('single-location'); + expect( + as(result.current, 'prepare').prepareResult!.locations, + ).not.toEqual([]); + + // switch to 'finish' + act(() => as(result.current, 'review').onReview(locationR)); + + expect(result.current.activeState).toBe('finish'); + expect(result.current.activeFlow).toBe('single-location'); + }); + }); + + describe('onGoBack', () => { + it('should work', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current.activeStepNumber).toBe(0); + expect(result.current.onGoBack).toBeUndefined(); + + act(() => + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + ), + ); + expect(result.current.activeStepNumber).toBe(1); + + expect(result.current.onGoBack).not.toBeUndefined(); + act(() => result.current.onGoBack!()); + expect(result.current.activeStepNumber).toBe(0); + + act(() => + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + ), + ); + act(() => as(result.current, 'prepare').onPrepare(locationAP)); + expect(result.current.activeStepNumber).toBe(2); + + expect(result.current.onGoBack).not.toBeUndefined(); + act(() => result.current.onGoBack!()); + expect(result.current.activeStepNumber).toBe(1); + + act(() => as(result.current, 'prepare').onPrepare(locationAP)); + act(() => as(result.current, 'review').onReview(locationR)); + expect(result.current.activeStepNumber).toBe(3); + }); + + it('should work for skipped', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current.activeStepNumber).toBe(0); + expect(result.current.onGoBack).toBeUndefined(); + + act(() => + as(result.current, 'analyze').onAnalysis( + 'single-location', + 'http://my-url', + locationAP, + { + prepareResult: locationAP, + }, + ), + ); + expect(result.current.activeStepNumber).toBe(2); + expect(result.current.onGoBack).not.toBeUndefined(); + + act(() => result.current.onGoBack!()); + expect(result.current.activeStepNumber).toBe(0); + expect(result.current.onGoBack).toBeUndefined(); + }); + + describe('should consider prepareNotRepeatable', () => { + it('as true', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current.onGoBack).toBeUndefined(); + + act(() => + as(result.current, 'analyze').onAnalysis( + 'multiple-locations', + 'http://my-url', + locationAP, + ), + ); + act(() => + as(result.current, 'prepare').onPrepare(locationAP, { + notRepeatable: true, + }), + ); + + expect(result.current.onGoBack).toBeUndefined(); + }); + + it('as false', async () => { + const { result } = renderHook(() => useImportState()); + await cleanup(); + + expect(result.current.onGoBack).toBeUndefined(); + + act(() => + as(result.current, 'analyze').onAnalysis( + 'multiple-locations', + 'http://my-url', + locationAP, + ), + ); + act(() => + as(result.current, 'prepare').onPrepare(locationAP, { + notRepeatable: false, + }), + ); + + expect(result.current.onGoBack).not.toBeUndefined(); + }); + }); + }); +}); diff --git a/plugins/catalog-import/src/components/useImportState.ts b/plugins/catalog-import/src/components/useImportState.ts new file mode 100644 index 0000000000..f042c991ef --- /dev/null +++ b/plugins/catalog-import/src/components/useImportState.ts @@ -0,0 +1,291 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, EntityName } from '@backstage/catalog-model'; +import { useReducer } from 'react'; +import { AnalyzeResult } from '../api'; + +// the configuration of the stepper +export type ImportFlows = + | 'unknown' + | 'single-location' + | 'multiple-locations' + | 'no-location'; + +// the available states of the stepper +type ImportStateTypes = 'analyze' | 'prepare' | 'review' | 'finish'; + +// result of the prepare state +export type PrepareResult = + | { + type: 'locations'; + locations: Array<{ + target: string; + entities: EntityName[]; + }>; + } + | { + type: 'repository'; + url: string; + integrationType: string; + pullRequest: { + url: string; + }; + locations: Array<{ + target: string; + entities: EntityName[]; + }>; + }; + +// result of the review result +export type ReviewResult = + | { + type: 'locations'; + locations: Array<{ + target: string; + entities: Entity[]; + }>; + } + | { + type: 'repository'; + url: string; + integrationType: string; + pullRequest: { + url: string; + }; + locations: Array<{ + target: string; + entities: Entity[]; + }>; + }; + +// function type for the 'analysis' -> 'prepare'/'review' transition +type onAnalysisFn = ( + flow: ImportFlows, + url: string, + result: AnalyzeResult, + opts?: { prepareResult?: PrepareResult }, +) => void; + +// function type for the 'prepare' -> 'review' transition +type onPrepareFn = ( + result: PrepareResult, + opts?: { notRepeatable?: boolean }, +) => void; + +// function type for the 'review' -> 'finish' transition +type onReviewFn = (result: ReviewResult) => void; + +// the type interfaces that are available in each state. every state provides +// already known information and means to go to the next, or the previous step. +type State = + | { + activeState: 'analyze'; + onAnalysis: onAnalysisFn; + } + | { + activeState: 'prepare'; + analyzeResult: AnalyzeResult; + prepareResult?: PrepareResult; + onPrepare: onPrepareFn; + } + | { + activeState: 'review'; + analyzeResult: AnalyzeResult; + prepareResult: PrepareResult; + onReview: onReviewFn; + } + | { + activeState: 'finish'; + analyzeResult: AnalyzeResult; + prepareResult: PrepareResult; + reviewResult: ReviewResult; + }; + +export type ImportState = State & { + activeFlow: ImportFlows; + activeStepNumber: number; + analysisUrl?: string; + + onGoBack?: () => void; + onReset: () => void; +}; + +type ReducerActions = + | { type: 'onAnalysis'; args: Parameters } + | { type: 'onPrepare'; args: Parameters } + | { type: 'onReview'; args: Parameters } + | { type: 'onGoBack' } + | { type: 'onReset'; initialUrl?: string }; + +type ReducerState = { + activeFlow: ImportFlows; + activeState: ImportStateTypes; + analysisUrl?: string; + analyzeResult?: AnalyzeResult; + prepareResult?: PrepareResult; + reviewResult?: ReviewResult; + + previousStates: ImportStateTypes[]; +}; + +function init(initialUrl?: string): ReducerState { + return { + activeFlow: 'unknown', + activeState: 'analyze', + analysisUrl: initialUrl, + previousStates: [], + }; +} + +function reducer(state: ReducerState, action: ReducerActions): ReducerState { + switch (action.type) { + case 'onAnalysis': { + if (state.activeState !== 'analyze') { + return state; + } + + const { activeState, previousStates } = state; + const [activeFlow, analysisUrl, analyzeResult, opts] = action.args; + + return { + ...state, + analysisUrl, + activeFlow, + analyzeResult, + prepareResult: opts?.prepareResult, + + activeState: opts?.prepareResult === undefined ? 'prepare' : 'review', + previousStates: previousStates.concat(activeState), + }; + } + + case 'onPrepare': { + if (state.activeState !== 'prepare') { + return state; + } + + const { activeState, previousStates } = state; + const [prepareResult, opts] = action.args; + + return { + ...state, + prepareResult, + + activeState: 'review', + previousStates: opts?.notRepeatable + ? [] + : previousStates.concat(activeState), + }; + } + + case 'onReview': { + if (state.activeState !== 'review') { + return state; + } + + const { activeState, previousStates } = state; + const [reviewResult] = action.args; + + return { + ...state, + reviewResult, + + activeState: 'finish', + previousStates: previousStates.concat(activeState), + }; + } + + case 'onGoBack': { + const { activeState, previousStates } = state; + + return { + ...state, + + activeState: + previousStates.length > 0 + ? previousStates[previousStates.length - 1] + : activeState, + previousStates: previousStates.slice(0, previousStates.length - 1), + }; + } + + case 'onReset': + return { + ...init(action.initialUrl), + + // we keep the old reviewResult since the form is animated and an + // undefined value might crash the last step. + reviewResult: state.reviewResult, + }; + + default: + throw new Error(); + } +} + +/** + * A hook that manages the state machine of the form. It handles different flows + * which each can implement up to four states: + * 1. analyze + * 2. preview (skippable from analyze->review) + * 3. review + * 4. finish + * + * @param options options + */ +export const useImportState = (options?: { + initialUrl?: string; +}): ImportState => { + const [state, dispatch] = useReducer(reducer, options?.initialUrl, init); + + const { activeFlow, activeState, analysisUrl, previousStates } = state; + + return { + activeFlow, + activeState, + activeStepNumber: ['analyze', 'prepare', 'review', 'finish'].indexOf( + activeState, + ), + analysisUrl: analysisUrl, + + analyzeResult: state.analyzeResult!, + prepareResult: state.prepareResult!, + reviewResult: state.reviewResult!, + + onAnalysis: (flow, url, result, opts) => + dispatch({ + type: 'onAnalysis', + args: [flow, url, result, opts], + }), + + onPrepare: (result, opts) => + dispatch({ + type: 'onPrepare', + args: [result, opts], + }), + + onReview: result => dispatch({ type: 'onReview', args: [result] }), + + onGoBack: + previousStates.length > 0 + ? () => dispatch({ type: 'onGoBack' }) + : undefined, + + onReset: () => + dispatch({ type: 'onReset', initialUrl: options?.initialUrl }), + }; +}; diff --git a/plugins/catalog-import/src/index.ts b/plugins/catalog-import/src/index.ts index 7b4fc17121..562e0499f3 100644 --- a/plugins/catalog-import/src/index.ts +++ b/plugins/catalog-import/src/index.ts @@ -20,4 +20,5 @@ export { CatalogImportPage, } from './plugin'; export { Router } from './components/Router'; +export * from './components'; export * from './api'; diff --git a/plugins/catalog-import/src/mocks/analyze-location-POST-response.json b/plugins/catalog-import/src/mocks/analyze-location-POST-response.json deleted file mode 100644 index eaadf1054a..0000000000 --- a/plugins/catalog-import/src/mocks/analyze-location-POST-response.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "existingEntityFiles": [], - "generateEntities": [ - { - "entity": { - "apiVersion": "backstage.io/v1alpha1", - "kind": "Component", - "metadata": { - "name": "somerepo", - "annotations": { - "github.com/project-slug": "someuser/somerepo" - } - }, - "spec": { - "type": "other", - "lifecycle": "unknown" - } - }, - "fields": [] - } - ] -} diff --git a/plugins/catalog-import/src/mocks/locations-POST-response.json b/plugins/catalog-import/src/mocks/locations-POST-response.json deleted file mode 100644 index c44a0f4d61..0000000000 --- a/plugins/catalog-import/src/mocks/locations-POST-response.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "location": { - "id": "d4a64359-a709-4c91-a9de-0905a033bf22", - "type": "url", - "target": "https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml" - }, - "entities": [ - { - "metadata": { - "namespace": "default", - "annotations": { - "backstage.io/managed-by-location": "url:https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml", - "github.com/project-slug": "someusername/somerepo" - }, - "name": "somerepo", - "uid": "e992d5ee-7c70-4316-90cf-325f1a0a5146", - "etag": "YWE2M2Q5MzgtNjdkNi00N2QwLWJkZjYtNDM0MTMzMDI4Y2I0", - "generation": 1 - }, - "apiVersion": "backstage.io/v1alpha1", - "kind": "Component", - "spec": { - "type": "other", - "lifecycle": "unknown", - "owner": "unknown" - }, - "relations": [ - { - "target": { - "kind": "group", - "namespace": "default", - "name": "unknown" - }, - "type": "ownedBy" - } - ] - } - ] -} diff --git a/plugins/catalog-import/src/plugin.ts b/plugins/catalog-import/src/plugin.ts index 42210d0ed1..18b3124b3b 100644 --- a/plugins/catalog-import/src/plugin.ts +++ b/plugins/catalog-import/src/plugin.ts @@ -15,16 +15,17 @@ */ import { + identityApiRef, + configApiRef, createApiFactory, createPlugin, + createRoutableExtension, createRouteRef, discoveryApiRef, githubAuthApiRef, - configApiRef, - createRoutableExtension, } from '@backstage/core'; -import { catalogImportApiRef } from './api/CatalogImportApi'; -import { CatalogImportClient } from './api/CatalogImportClient'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogImportApiRef, CatalogImportClient } from './api'; export const rootRouteRef = createRouteRef({ path: '', @@ -39,10 +40,24 @@ export const catalogImportPlugin = createPlugin({ deps: { discoveryApi: discoveryApiRef, githubAuthApi: githubAuthApiRef, + identityApi: identityApiRef, configApi: configApiRef, + catalogApi: catalogApiRef, }, - factory: ({ discoveryApi, githubAuthApi, configApi }) => - new CatalogImportClient({ discoveryApi, githubAuthApi, configApi }), + factory: ({ + discoveryApi, + githubAuthApi, + identityApi, + configApi, + catalogApi, + }) => + new CatalogImportClient({ + discoveryApi, + githubAuthApi, + configApi, + identityApi, + catalogApi, + }), }), ], routes: { diff --git a/plugins/catalog-import/src/util/types.ts b/plugins/catalog-import/src/types.ts similarity index 100% rename from plugins/catalog-import/src/util/types.ts rename to plugins/catalog-import/src/types.ts diff --git a/plugins/catalog-import/src/util/useGithubRepos.ts b/plugins/catalog-import/src/util/useGithubRepos.ts deleted file mode 100644 index a2d299b9f4..0000000000 --- a/plugins/catalog-import/src/util/useGithubRepos.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as YAML from 'yaml'; -import { useApi, configApiRef } from '@backstage/core'; -import { catalogImportApiRef } from '../api/CatalogImportApi'; -import { ConfigSpec } from '../components/ImportComponentPage'; -import parseGitUrl from 'git-url-parse'; - -// TODO: (O5ten) Refactor into a core API instead of direct usage like this -// https://github.com/backstage/backstage/pull/3613#issuecomment-7408929430 -import { - GitHubIntegrationConfig, - readGitHubIntegrationConfigs, -} from '@backstage/integration'; - -export function useGithubRepos() { - const api = useApi(catalogImportApiRef); - const config = useApi(configApiRef); - - const getGithubIntegrationConfig = (location: string) => { - const { - name: repoName, - owner: ownerName, - resource: hostname, - } = parseGitUrl(location); - - const configs = readGitHubIntegrationConfigs( - config.getOptionalConfigArray('integrations.github') ?? [], - ); - const githubIntegrationConfig = configs.find(v => v.host === hostname); - if (!githubIntegrationConfig) { - throw new Error( - `Unable to locate github-integration for repo-location, ${location}`, - ); - } - return { - repoName, - ownerName, - githubIntegrationConfig, - }; - }; - - const submitPrToRepo = async (selectedRepo: ConfigSpec) => { - const { - repoName, - ownerName, - githubIntegrationConfig, - } = getGithubIntegrationConfig(selectedRepo.location); - const submitPRResponse = await api - .submitPrToRepo({ - owner: ownerName, - repo: repoName, - fileContent: selectedRepo.config - .map(entity => `---\n${YAML.stringify(entity)}`) - .join('\n'), - githubIntegrationConfig, - }) - .catch(e => { - throw new Error(`Failed to submit PR to repo, ${e.message}`); - }); - - await api - .createRepositoryLocation({ - location: submitPRResponse.location, - }) - .catch(e => { - throw new Error(`Failed to create repository location, ${e.message}`); - }); - - return submitPRResponse; - }; - - const checkForExistingCatalogInfo = async ( - location: string, - ): Promise<{ exists: boolean; url?: string }> => { - let githubConfig: { - repoName: string; - ownerName: string; - githubIntegrationConfig: GitHubIntegrationConfig; - }; - try { - githubConfig = getGithubIntegrationConfig(location); - } catch (e) { - return { exists: false }; - } - return await api - .checkForExistingCatalogInfo({ - owner: githubConfig.ownerName, - repo: githubConfig.repoName, - githubIntegrationConfig: githubConfig.githubIntegrationConfig, - }) - .catch(e => { - throw new Error( - `Failed to inspect repository for existing catalog-info.yaml, ${e.message}`, - ); - }); - }; - - return { - submitPrToRepo, - checkForExistingCatalogInfo, - generateEntityDefinitions: (repo: string) => - api.generateEntityDefinitions({ repo }), - addLocation: (location: string) => - api.createRepositoryLocation({ location }), - }; -} diff --git a/plugins/catalog-import/src/util/validate.test.ts b/plugins/catalog-import/src/util/validate.test.ts deleted file mode 100644 index 139c7fb0f8..0000000000 --- a/plugins/catalog-import/src/util/validate.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ComponentIdValidators } from './validate'; - -describe('ComponentIdValidators', () => { - describe('httpsValidator validator', () => { - const errorMessage = 'Must start with https://.'; - test.each([ - [true, 'https://example.com'], - [errorMessage, 'http://example.com'], - [errorMessage, 'example.com'], - [errorMessage, 'www.example.com'], - [errorMessage, ''], - [errorMessage, undefined], - ])('should return %p for %s', (expected: string | boolean, arg: any) => { - expect(ComponentIdValidators.httpsValidator(arg)).toBe(expected); - }); - }); -}); diff --git a/plugins/catalog-react/CHANGELOG.md b/plugins/catalog-react/CHANGELOG.md index 90b7c80caf..38cc17e1ba 100644 --- a/plugins/catalog-react/CHANGELOG.md +++ b/plugins/catalog-react/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog-react +## 0.0.3 + +### Patch Changes + +- 19d354c78: Make `EntityRefLink` a `React.forwardRef` in order to use it as root component in other components like `ListItem`. +- Updated dependencies [6ed2b47d6] +- Updated dependencies [72b96e880] +- Updated dependencies [b51ee6ece] + - @backstage/catalog-client@0.3.6 + - @backstage/core@0.6.1 + ## 0.0.2 ### Patch Changes diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 64307f6846..649f3b2ff6 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-react", - "version": "0.0.2", + "version": "0.0.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -28,9 +28,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.5", + "@backstage/catalog-client": "^0.3.6", "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", "@material-ui/core": "^4.11.0", "@types/react": "^16.9", "react": "^16.13.1", @@ -40,8 +40,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx index ae6f807275..50a80af12a 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx @@ -15,13 +15,12 @@ */ import { Entity, - EntityName, ENTITY_DEFAULT_NAMESPACE, + EntityName, } from '@backstage/catalog-model'; -import { Link } from '@material-ui/core'; +import { Link } from '@backstage/core'; import React from 'react'; import { generatePath } from 'react-router'; -import { Link as RouterLink } from 'react-router-dom'; import { entityRoute } from '../../routes'; import { formatEntityRefTitle } from './format'; @@ -31,41 +30,42 @@ type EntityRefLinkProps = { children?: React.ReactNode; }; -export const EntityRefLink = ({ - entityRef, - defaultKind, - children, -}: EntityRefLinkProps) => { - let kind; - let namespace; - let name; +export const EntityRefLink = React.forwardRef( + (props, ref) => { + const { entityRef, defaultKind, children, ...linkProps } = props; - if ('metadata' in entityRef) { - kind = entityRef.kind; - namespace = entityRef.metadata.namespace; - name = entityRef.metadata.name; - } else { - kind = entityRef.kind; - namespace = entityRef.namespace; - name = entityRef.name; - } + let kind; + let namespace; + let name; - kind = kind.toLowerCase(); + if ('metadata' in entityRef) { + kind = entityRef.kind; + namespace = entityRef.metadata.namespace; + name = entityRef.metadata.name; + } else { + kind = entityRef.kind; + namespace = entityRef.namespace; + name = entityRef.name; + } - const routeParams = { - kind, - namespace: namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, - name, - }; + kind = kind.toLowerCase(); - // TODO: Use useRouteRef here to generate the path - return ( - - {children} - {!children && formatEntityRefTitle(entityRef, { defaultKind })} - - ); -}; + const routeParams = { + kind, + namespace: namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, + name, + }; + + // TODO: Use useRouteRef here to generate the path + return ( + + {children} + {!children && formatEntityRefTitle(entityRef, { defaultKind })} + + ); + }, +); diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index eb4c9a30d7..d786fc2f15 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,40 @@ # @backstage/plugin-catalog +## 0.3.1 + +### Patch Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- ca559171b: bug fix: 3310 fixes reloading entities with the default owned filter +- f5e564cd6: Improve display of error messages +- 1df75733e: Adds an `EntityLinksCard` component to display `entity.metadata.links` on entity pages. The new component is a companion for the new [Entity Links](https://backstage.io/docs/features/software-catalog/descriptor-format#links-optional) catalog model addition. + + Here is an example usage within an `EntityPage.tsx`. + + ```tsx + // in packages/app/src/components/catalog/EntityPage.tsx + const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( + + + + // or ... + + + + ); + ``` + +- e5da858d7: Removed unused functions and the moment library. #4278 +- 9230d07e7: Fix whitespace around variable in unregister error dialog box +- Updated dependencies [6ed2b47d6] +- Updated dependencies [72b96e880] +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/catalog-client@0.3.6 + - @backstage/plugin-scaffolder@0.5.0 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.3.0 ### Minor Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index c806c58e67..072acc6472 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.3.0", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,11 +30,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.5", + "@backstage/catalog-client": "^0.3.6", "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", - "@backstage/plugin-catalog-react": "^0.0.2", - "@backstage/plugin-scaffolder": "^0.4.2", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", + "@backstage/plugin-scaffolder": "^0.5.0", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,7 +42,6 @@ "@types/react": "^16.9", "classnames": "^2.2.6", "git-url-parse": "^11.4.4", - "moment": "^2.26.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-helmet": "6.1.0", @@ -53,8 +52,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@microsoft/microsoft-graph-types": "^1.25.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/catalog/src/CatalogClientWrapper.test.ts b/plugins/catalog/src/CatalogClientWrapper.test.ts new file mode 100644 index 0000000000..87dcd8f74b --- /dev/null +++ b/plugins/catalog/src/CatalogClientWrapper.test.ts @@ -0,0 +1,169 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CatalogClient } from '@backstage/catalog-client'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; +import { CatalogClientWrapper } from './CatalogClientWrapper'; + +jest.mock('@backstage/catalog-client'); +const MockedCatalogClient = CatalogClient as jest.Mock; + +const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; +const discoveryApi: DiscoveryApi = { + async getBaseUrl(_pluginId) { + return mockBaseUrl; + }, +}; +const identityApi: IdentityApi = { + getUserId() { + return 'jane-fonda'; + }, + getProfile() { + return { email: 'jane-fonda@spotify.com' }; + }, + async getIdToken() { + return Promise.resolve('fake-id-token'); + }, + async signOut() { + return Promise.resolve(); + }, +}; +const guestIdentityApi: IdentityApi = { + getUserId() { + return 'guest'; + }, + getProfile() { + return {}; + }, + async getIdToken() { + return Promise.resolve(undefined); + }, + async signOut() { + return Promise.resolve(); + }, +}; + +describe('CatalogClientWrapper', () => { + beforeEach(() => { + MockedCatalogClient.mockClear(); + }); + + describe('getEntities', () => { + it('injects authorization token', async () => { + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi, + }); + await client.getEntities(); + const getEntities = MockedCatalogClient.mock.instances[0].getEntities; + expect(getEntities).toHaveBeenCalledWith(undefined, { + token: 'fake-id-token', + }); + expect(getEntities).toHaveBeenCalledTimes(1); + }); + }); + + describe('getLocationById', () => { + it('omits authorization token when guest', async () => { + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi: guestIdentityApi, + }); + await client.getLocationById('42'); + const getLocationById = + MockedCatalogClient.mock.instances[0].getLocationById; + expect(getLocationById).toHaveBeenCalledWith('42', {}); + expect(getLocationById).toHaveBeenCalledTimes(1); + }); + }); + + describe('getEntityByName', () => { + const name = { + kind: 'kind', + namespace: 'namespace', + name: 'name', + }; + it('injects authorization token', async () => { + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi, + }); + await client.getEntityByName(name); + const getEntityByName = + MockedCatalogClient.mock.instances[0].getEntityByName; + expect(getEntityByName).toHaveBeenCalledWith(name, { + token: 'fake-id-token', + }); + expect(getEntityByName).toHaveBeenCalledTimes(1); + }); + }); + + describe('addLocation', () => { + const location = { target: 'target' }; + it('injects authorization token', async () => { + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi, + }); + await client.addLocation(location); + const addLocation = MockedCatalogClient.mock.instances[0].addLocation; + expect(addLocation).toHaveBeenCalledWith(location, { + token: 'fake-id-token', + }); + expect(addLocation).toHaveBeenCalledTimes(1); + }); + }); + + describe('getLocationByEntity', () => { + const entity = { + apiVersion: 'apiVersion', + kind: 'kind', + metadata: { + name: 'name', + }, + }; + it('injects authorization token', async () => { + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi, + }); + await client.getLocationByEntity(entity); + const getLocationByEntity = + MockedCatalogClient.mock.instances[0].getLocationByEntity; + expect(getLocationByEntity).toHaveBeenCalledWith(entity, { + token: 'fake-id-token', + }); + expect(getLocationByEntity).toHaveBeenCalledTimes(1); + }); + }); + + describe('removeEntityByUid', () => { + it('injects authorization token', async () => { + const uid = 'uid'; + const client = new CatalogClientWrapper({ + client: new MockedCatalogClient({ discoveryApi }), + identityApi, + }); + await client.removeEntityByUid(uid); + const removeEntityByUid = + MockedCatalogClient.mock.instances[0].removeEntityByUid; + expect(removeEntityByUid).toHaveBeenCalledWith(uid, { + token: 'fake-id-token', + }); + expect(removeEntityByUid).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/plugins/catalog/src/CatalogClientWrapper.ts b/plugins/catalog/src/CatalogClientWrapper.ts new file mode 100644 index 0000000000..42e9d872d4 --- /dev/null +++ b/plugins/catalog/src/CatalogClientWrapper.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, EntityName, Location } from '@backstage/catalog-model'; +import { + AddLocationRequest, + AddLocationResponse, + CatalogApi, + CatalogEntitiesRequest, + CatalogListResponse, + CatalogClient, +} from '@backstage/catalog-client'; +import { IdentityApi } from '@backstage/core'; + +type CatalogRequestOptions = { + token?: string; +}; + +/** + * CatalogClient wrapper that injects identity token for all requests + */ +export class CatalogClientWrapper implements CatalogApi { + private readonly identityApi: IdentityApi; + private readonly client: CatalogClient; + + constructor(options: { client: CatalogClient; identityApi: IdentityApi }) { + this.client = options.client; + this.identityApi = options.identityApi; + } + + async getLocationById( + id: String, + options?: CatalogRequestOptions, + ): Promise { + return await this.client.getLocationById(id, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } + + async getEntities( + request?: CatalogEntitiesRequest, + options?: CatalogRequestOptions, + ): Promise> { + return await this.client.getEntities(request, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } + + async getEntityByName( + compoundName: EntityName, + options?: CatalogRequestOptions, + ): Promise { + return await this.client.getEntityByName(compoundName, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } + + async addLocation( + request: AddLocationRequest, + options?: CatalogRequestOptions, + ): Promise { + return await this.client.addLocation(request, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } + + async getLocationByEntity( + entity: Entity, + options?: CatalogRequestOptions, + ): Promise { + return await this.client.getLocationByEntity(entity, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } + + async removeEntityByUid( + uid: string, + options?: CatalogRequestOptions, + ): Promise { + return await this.client.removeEntityByUid(uid, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } +} diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index 3038fc3c5c..ada827115d 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -14,8 +14,9 @@ * limitations under the License. */ -import React from 'react'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { render } from '@testing-library/react'; +import React from 'react'; import { AboutCard } from './AboutCard'; describe(' GitHub', () => { @@ -36,7 +37,11 @@ describe(' GitHub', () => { lifecycle: 'production', }, }; - const { getByText } = render(); + const { getByText } = render( + + + , + ); expect(getByText('service')).toBeInTheDocument(); expect(getByText('View Source').closest('a')).toHaveAttribute( 'href', @@ -67,7 +72,11 @@ describe(' GitLab', () => { lifecycle: 'production', }, }; - const { getByText } = render(); + const { getByText } = render( + + + , + ); expect(getByText('service')).toBeInTheDocument(); expect(getByText('View Source').closest('a')).toHaveAttribute( 'href', @@ -98,7 +107,11 @@ describe(' BitBucket', () => { lifecycle: 'production', }, }; - const { getByText } = render(); + const { getByText } = render( + + + , + ); expect(getByText('service')).toBeInTheDocument(); expect(getByText('View Source').closest('a')).toHaveAttribute( 'href', diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 1549d87b8a..04c2a78869 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -19,6 +19,8 @@ import { ENTITY_DEFAULT_NAMESPACE, RELATION_PROVIDES_API, } from '@backstage/catalog-model'; +import { HeaderIconLinkRow } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { Card, CardContent, @@ -27,14 +29,13 @@ import { IconButton, makeStyles, } from '@material-ui/core'; -import ExtensionIcon from '@material-ui/icons/Extension'; import DocsIcon from '@material-ui/icons/Description'; import EditIcon from '@material-ui/icons/Edit'; +import ExtensionIcon from '@material-ui/icons/Extension'; import GitHubIcon from '@material-ui/icons/GitHub'; import React from 'react'; import { findLocationForEntityMeta } from '../../data/utils'; import { createEditLink, determineUrlType } from '../createEditLink'; -import { HeaderIconLinkRow } from '@backstage/core'; import { AboutContent } from './AboutContent'; const useStyles = makeStyles({ @@ -76,12 +77,14 @@ function getCodeLinkInfo(entity: Entity): CodeLinkInfo { } type AboutCardProps = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export function AboutCard({ entity, variant }: AboutCardProps) { +export function AboutCard({ variant }: AboutCardProps) { const classes = useStyles(); + const { entity } = useEntity(); const codeLink = getCodeLinkInfo(entity); // TODO: Also support RELATION_CONSUMES_API here const hasApis = entity.relations?.some(r => r.type === RELATION_PROVIDES_API); diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx index 90a1e15ad0..6de4c318b5 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -80,6 +80,14 @@ type Props = { onChange?: OnChangeCallback; }; +/** + * Sidebar filter type and human readable label for it. owned/starred/all + */ +export type CatalogFilterType = { + id: string; + label: string; +}; + /** * The main filter group in the sidebar, toggling owned/starred/all. */ diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 42c9cf1117..6d4d18e4f2 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -30,7 +30,7 @@ import { } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; import { CatalogPage } from './CatalogPage'; @@ -128,4 +128,24 @@ describe('CatalogPage', () => { fireEvent.click(getByText(/All/)); expect(await findByText(/All \(2\)/)).toBeInTheDocument(); }); + // this test is for fixing the bug after favoriting an entity, the matching entities defaulting + // to "owned" filter and not based on the selected filter + it('should render the correct entities filtered on the selectedfilter', async () => { + const { findByText, findAllByTitle, getByText } = renderWrapped( + , + ); + expect(await findByText(/Owned \(1\)/)).toBeInTheDocument(); + expect(await findByText(/Starred/)).toBeInTheDocument(); + fireEvent.click(getByText(/Starred/)); + expect(await findByText(/Starred \(0\)/)).toBeInTheDocument(); + fireEvent.click(getByText(/All/)); + expect(await findByText(/All \(2\)/)).toBeInTheDocument(); + + const starredIcons = await findAllByTitle('Add to favorites'); + fireEvent.click(starredIcons[0]); + expect(await findByText(/All \(2\)/)).toBeInTheDocument(); + + fireEvent.click(getByText(/Starred/)); + waitFor(() => expect(findByText(/Starred \(1\)/)).toBeInTheDocument()); + }); }); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 1298d5e190..a2ebc63825 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -31,7 +31,11 @@ import React, { useCallback, useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; import { useStarredEntities } from '../../hooks/useStarredEntities'; -import { ButtonGroup, CatalogFilter } from '../CatalogFilter/CatalogFilter'; +import { + ButtonGroup, + CatalogFilter, + CatalogFilterType, +} from '../CatalogFilter/CatalogFilter'; import { CatalogTable } from '../CatalogTable/CatalogTable'; import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; import { useOwnUser } from '../useOwnUser'; @@ -65,7 +69,9 @@ const CatalogPageContents = () => { const errorApi = useApi(errorApiRef); const { isStarredEntity } = useStarredEntities(); const [selectedTab, setSelectedTab] = useState(); - const [selectedSidebarItem, setSelectedSidebarItem] = useState(); + const [selectedSidebarItem, setSelectedSidebarItem] = useState< + CatalogFilterType + >(); const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; const addMockData = useCallback(async () => { @@ -180,13 +186,15 @@ const CatalogPageContents = () => {
setSelectedSidebarItem(label)} - initiallySelected="owned" + onChange={({ label, id }) => + setSelectedSidebarItem({ label, id }) + } + initiallySelected={selectedSidebarItem?.id ?? 'owned'} />
{ - const valid = isValidDateAndFormat( - '1970-01-01T00:00:00', - 'YYYY-MM-DD[T]HH:mm:ss', - ); - const invalid = isValidDateAndFormat( - '1970/01/01T00:00:00', - 'YYYY-MM-DD[T]HH:mm:ss', - ); - expect(valid).toBe(true); - expect(invalid).toBe(false); -}); +import { getTimeBasedGreeting } from './timeUtil'; it('has greeting and language', () => { const greeting = getTimeBasedGreeting(); diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index e30f54a275..82cf556281 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -25,7 +25,6 @@ import { import { makeStyles } from '@material-ui/core/styles'; import Cancel from '@material-ui/icons/Cancel'; import MoreVert from '@material-ui/icons/MoreVert'; -import SwapHoriz from '@material-ui/icons/SwapHoriz'; import React, { useState } from 'react'; // TODO(freben): It should probably instead be the case that Header sets the theme text color to white inside itself unconditionally instead @@ -82,12 +81,6 @@ export const EntityContextMenu = ({ onUnregisterEntity }: Props) => { Unregister entity - - - - - Move repository - diff --git a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx new file mode 100644 index 0000000000..d54eac788c --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, EntityLink } from '@backstage/catalog-model'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { EntityLinksCard } from './EntityLinksCard'; + +describe('EntityLinksCard', () => { + const createEntity = (links: EntityLink[] = []): Entity => + ({ + metadata: { + links, + }, + } as Entity); + + const createLink = ({ + url = 'https://dashboard.dashexample.com', + title = 'admin dashboard', + icon = undefined, + }: Partial = {}): EntityLink => ({ + url, + title, + icon, + }); + + it('should render a link', async () => { + const links: EntityLink[] = [createLink()]; + const entityContextValue = { + entity: createEntity(links), + loading: false, + error: undefined, + }; + + const { queryByText } = await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + expect(queryByText('admin dashboard')).toBeInTheDocument(); + expect(queryByText('derp')).not.toBeInTheDocument(); + }); + + it('should show empty state', async () => { + const entityContextValue = { + entity: createEntity([]), + loading: false, + error: undefined, + }; + + const { queryByText } = await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + expect( + queryByText(/.*No links defined for this entity.*/), + ).toBeInTheDocument(); + expect(queryByText('admin dashboard')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx new file mode 100644 index 0000000000..b0c3db2333 --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { IconComponent, IconKey, InfoCard, useApp } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import LanguageIcon from '@material-ui/icons/Language'; +import React from 'react'; +import { EntityLinksEmptyState } from './EntityLinksEmptyState'; +import { LinksGridList } from './LinksGridList'; +import { ColumnBreakpoints } from './types'; + +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; + cols?: ColumnBreakpoints | number; +}; + +export const EntityLinksCard = ({ cols = undefined }: Props) => { + const { entity } = useEntity(); + const app = useApp(); + + const iconResolver = (key: IconKey | undefined): IconComponent => { + return app.getSystemIcon(key ?? '') ?? LanguageIcon; + }; + + const links = entity?.metadata?.links; + + return ( + + {!links || links.length === 0 ? ( + + ) : ( + ({ + text: title ?? url, + href: url, + Icon: iconResolver(icon), + }))} + /> + )} + + ); +}; diff --git a/plugins/catalog/src/components/EntityLinksCard/EntityLinksEmptyState.tsx b/plugins/catalog/src/components/EntityLinksCard/EntityLinksEmptyState.tsx new file mode 100644 index 0000000000..3ced50decc --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/EntityLinksEmptyState.tsx @@ -0,0 +1,65 @@ +/* + * 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 { CodeSnippet } from '@backstage/core'; +import { BackstageTheme } from '@backstage/theme'; +import { Button, makeStyles, Typography } from '@material-ui/core'; +import React from 'react'; + +const ENTITY_YAML = `metadata: + name: example + links: + - url: https://dashboard.example.com + title: My Dashboard + icon: dashboard`; + +const useStyles = makeStyles(theme => ({ + code: { + borderRadius: 6, + margin: `${theme.spacing(2)}px 0px`, + background: theme.palette.type === 'dark' ? '#444' : '#fff', + }, +})); + +export const EntityLinksEmptyState = () => { + const classes = useStyles(); + + return ( + <> + + No links defined for this entity. You can add links to your entity YAML + as shown in the highlighted example below: + +
+ +
+ + + ); +}; diff --git a/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx b/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx new file mode 100644 index 0000000000..8caf37e70d --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import CloudIcon from '@material-ui/icons/Cloud'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { IconLink } from './IconLink'; + +describe('IconLink', () => { + it('should render an icon link', () => { + const rendered = render( + + + , + ); + + expect(rendered.queryByText('I am Link')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/EntityLinksCard/IconLink.tsx b/plugins/catalog/src/components/EntityLinksCard/IconLink.tsx new file mode 100644 index 0000000000..32ea085889 --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/IconLink.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Link, IconComponent } from '@backstage/core'; +import { Grid, makeStyles, Typography } from '@material-ui/core'; +import LanguageIcon from '@material-ui/icons/Language'; +import React from 'react'; + +const useStyles = makeStyles({ + svgIcon: { + display: 'inline-block', + '& svg': { + display: 'inline-block', + fontSize: 'inherit', + verticalAlign: 'baseline', + }, + }, +}); + +export const IconLink = ({ + href, + text, + Icon, +}: { + href: string; + text?: string; + Icon?: IconComponent; +}) => { + const classes = useStyles(); + + return ( + + + + {Icon ? : } + + + + + {text || href} + + + + ); +}; diff --git a/plugins/catalog/src/components/EntityLinksCard/LinksGridList.tsx b/plugins/catalog/src/components/EntityLinksCard/LinksGridList.tsx new file mode 100644 index 0000000000..9ef77a4bbf --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/LinksGridList.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IconComponent } from '@backstage/core'; +import { GridList, GridListTile } from '@material-ui/core'; +import React from 'react'; +import { IconLink } from './IconLink'; +import { ColumnBreakpoints } from './types'; +import { useDynamicColumns } from './useDynamicColumns'; + +export type LinksGridListItem = { + href: string; + text?: string; + Icon?: IconComponent; +}; + +type Props = { + items: LinksGridListItem[]; + cols?: ColumnBreakpoints | number; +}; + +export const LinksGridList = ({ items, cols = undefined }: Props) => { + const numOfCols = useDynamicColumns(cols); + + return ( + + {items.map(({ text, href, Icon }, i) => ( + + + + ))} + + ); +}; diff --git a/plugins/catalog-import/src/util/validate.ts b/plugins/catalog/src/components/EntityLinksCard/index.ts similarity index 76% rename from plugins/catalog-import/src/util/validate.ts rename to plugins/catalog/src/components/EntityLinksCard/index.ts index 056131aaae..d893d3a233 100644 --- a/plugins/catalog-import/src/util/validate.ts +++ b/plugins/catalog/src/components/EntityLinksCard/index.ts @@ -14,8 +14,4 @@ * limitations under the License. */ -export const ComponentIdValidators = { - httpsValidator: (value: any) => - (typeof value === 'string' && value.match(/^https:\/\//) !== null) || - 'Must start with https://.', -}; +export { EntityLinksCard } from './EntityLinksCard'; diff --git a/plugins/catalog/src/components/EntityLinksCard/types.ts b/plugins/catalog/src/components/EntityLinksCard/types.ts new file mode 100644 index 0000000000..d7729fc216 --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +export type ColumnBreakpoints = Record; diff --git a/plugins/catalog/src/components/EntityLinksCard/useDynamicColumns.tsx b/plugins/catalog/src/components/EntityLinksCard/useDynamicColumns.tsx new file mode 100644 index 0000000000..d2b4168e0b --- /dev/null +++ b/plugins/catalog/src/components/EntityLinksCard/useDynamicColumns.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Theme, useMediaQuery } from '@material-ui/core'; +import { Breakpoint, ColumnBreakpoints } from './types'; + +const colDefaults: ColumnBreakpoints = { + xs: 1, + sm: 1, + md: 1, + lg: 2, + xl: 3, +}; + +export function useDynamicColumns( + cols: ColumnBreakpoints | number | undefined, +): number { + const matches: (Breakpoint | null)[] = [ + useMediaQuery((theme: Theme) => theme.breakpoints.up('xl')) ? 'xl' : null, + useMediaQuery((theme: Theme) => theme.breakpoints.up('lg')) ? 'lg' : null, + useMediaQuery((theme: Theme) => theme.breakpoints.up('md')) ? 'md' : null, + useMediaQuery((theme: Theme) => theme.breakpoints.up('sm')) ? 'sm' : null, + useMediaQuery((theme: Theme) => theme.breakpoints.up('xs')) ? 'xs' : null, + ]; + + let numOfCols = 1; + + if (typeof cols === 'number') { + numOfCols = cols; + } else { + const breakpoint = matches.find(k => k !== null) ?? 'xs'; + numOfCols = cols?.[breakpoint] ?? colDefaults[breakpoint]; + } + + return numOfCols; +} diff --git a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx index 06ea1eee27..ef4196e9c5 100644 --- a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx +++ b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx @@ -110,7 +110,7 @@ export const UnregisterEntityDialog = ({ {error.name === 'DeniedLocationException' ? ( <> You cannot unregister this entity, since it originates from a - protected Backstage configuration (location + protected Backstage configuration (location{' '} {`"${(error as DeniedLocationException).locationName}"`}). If you believe this is in error, please contact the{' '} {configApi.getOptionalString('app.title') ?? 'Backstage'}{' '} diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.ts b/plugins/catalog/src/filter/useEntityFilterGroup.ts index 242238e4f4..30214fad78 100644 --- a/plugins/catalog/src/filter/useEntityFilterGroup.ts +++ b/plugins/catalog/src/filter/useEntityFilterGroup.ts @@ -43,15 +43,19 @@ export const useEntityFilterGroup = ( filterGroupStates, } = context; - // Intentionally consider initial set only at mount time + // on state changes unregisters and registers the filtergroup + // ensure that it re-registers with the correct filter as the prop changes and not the default // eslint-disable-next-line react-hooks/exhaustive-deps - const initialMemo = useMemo(() => initialSelectedFilters?.slice(), []); + const initialMemo = useMemo(() => { + return initialSelectedFilters?.slice(); + }, [initialSelectedFilters]); // Register the group on mount, and unregister on unmount useEffect(() => { register(filterGroupId, filterGroup, initialMemo); return () => unregister(filterGroupId); - }, [register, unregister, filterGroupId, filterGroup, initialMemo]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [register, unregister, filterGroupId, filterGroup]); const setSelectedFilters = useCallback( (filters: string[]) => { diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 3ef79680c4..ce8518eb55 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -24,4 +24,6 @@ export { catalogPlugin as plugin, CatalogIndexPage, CatalogEntityPage, + EntityAboutCard, + EntityLinksCard, } from './plugin'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index c19925775d..c2b910ce68 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -19,21 +19,28 @@ import { createApiFactory, createPlugin, discoveryApiRef, + createComponentExtension, createRoutableExtension, + identityApiRef, } from '@backstage/core'; import { catalogApiRef, catalogRouteRef, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { CatalogClientWrapper } from './CatalogClientWrapper'; export const catalogPlugin = createPlugin({ id: 'catalog', apis: [ createApiFactory({ api: catalogApiRef, - deps: { discoveryApi: discoveryApiRef }, - factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }), + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new CatalogClientWrapper({ + client: new CatalogClient({ discoveryApi }), + identityApi, + }), }), ], routes: { @@ -59,3 +66,20 @@ export const CatalogEntityPage = catalogPlugin.provide( mountPoint: entityRouteRef, }), ); + +export const EntityAboutCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/AboutCard').then(m => m.AboutCard), + }, + }), +); + +export const EntityLinksCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/EntityLinksCard').then(m => m.EntityLinksCard), + }, + }), +); diff --git a/plugins/circleci/CHANGELOG.md b/plugins/circleci/CHANGELOG.md index 99715821a6..1c5d35aa70 100644 --- a/plugins/circleci/CHANGELOG.md +++ b/plugins/circleci/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-circleci +## 0.2.8 + +### Patch Changes + +- 6b26c9f41: Migrated to new composability API, exporting the plugin instance as `circleCIPlugin`, the entity page content as `EntityCircleCIContent`, and entity conditional as `isCircleCIAvailable`. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.2.7 ### Patch Changes diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 37e4425b23..6f741ab1ce 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-circleci", - "version": "0.2.7", + "version": "0.2.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,8 +32,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", - "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -51,8 +51,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/cloudbuild/CHANGELOG.md b/plugins/cloudbuild/CHANGELOG.md index ae3f8faada..8dccd1dbbd 100644 --- a/plugins/cloudbuild/CHANGELOG.md +++ b/plugins/cloudbuild/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-cloudbuild +## 0.2.9 + +### Patch Changes + +- 302795d10: Migrate to new composability API, exporting the plugin instance as `cloudbuildPlugin`, the entity content as `EntityCloudbuildContent`, the entity conditional as `isCloudbuildAvailable`, and entity cards as `EntityLatestCloudbuildRunCard` and `EntityLatestCloudbuildsForBranchCard`. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.2.8 ### Patch Changes diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index cbad4928b7..a00fe13fe0 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cloudbuild", - "version": "0.2.8", + "version": "0.2.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.0.2", - "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.3", + "@backstage/core": "^0.6.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -48,8 +48,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index 87bfd3af07..e672b33976 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-cost-insights +## 0.8.1 + +### Patch Changes + +- b33fa4cf4: fixes a bug in default dismiss form where other text input persists between reason selections +- d36660721: Fix snooze quarter option +- 02d6803e8: Migrated to new composability API, exporting the plugin instance as `costInsightsPlugin`, the root `'/cost-insights'` page as `CostInsightsPage`, the `'/cost-insights/investigating-growth'` page as `CostInsightsProjectGrowthInstructionsPage`, and the `'/cost-insights/labeling-jobs'` page as `CostInsightsLabelDataflowInstructionsPage`. +- Updated dependencies [b51ee6ece] + - @backstage/core@0.6.1 + ## 0.8.0 ### Minor Changes diff --git a/plugins/cost-insights/dev/index.tsx b/plugins/cost-insights/dev/index.tsx index bdbb4450fa..e4732fc364 100644 --- a/plugins/cost-insights/dev/index.tsx +++ b/plugins/cost-insights/dev/index.tsx @@ -16,10 +16,10 @@ import { createDevApp } from '@backstage/dev-utils'; import { costInsightsApiRef } from '../src/api'; import { ExampleCostInsightsClient } from '../src/client'; -import { plugin } from '../src/plugin'; +import { costInsightsPlugin } from '../src/plugin'; createDevApp() - .registerPlugin(plugin) + .registerPlugin(costInsightsPlugin) .registerApi({ api: costInsightsApiRef, deps: {}, diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 0184d50517..2f0511c217 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.8.0", + "version": "0.8.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/config": "^0.1.2", - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -56,8 +56,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/cost-insights/src/forms/AlertDismissForm.tsx b/plugins/cost-insights/src/forms/AlertDismissForm.tsx index 4271785958..47b4dc80cb 100644 --- a/plugins/cost-insights/src/forms/AlertDismissForm.tsx +++ b/plugins/cost-insights/src/forms/AlertDismissForm.tsx @@ -66,6 +66,9 @@ export const AlertDismissForm = forwardRef< }; const onReasonChange = (_: ChangeEvent, value: string) => { + if (other) { + setOther(null); + } setReason(value as AlertDismissReason); }; diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index 13ce929401..0ae9d05b09 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -14,7 +14,13 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + costInsightsPlugin, + costInsightsPlugin as plugin, + CostInsightsPage, + CostInsightsProjectGrowthInstructionsPage, + CostInsightsLabelDataflowInstructionsPage, +} from './plugin'; export * from './client'; export * from './api'; export { ProjectGrowthAlert, UnlabeledDataflowAlert } from './alerts'; diff --git a/plugins/cost-insights/src/plugin.test.ts b/plugins/cost-insights/src/plugin.test.ts index 6210e11d4c..87be3ff8a2 100644 --- a/plugins/cost-insights/src/plugin.test.ts +++ b/plugins/cost-insights/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { costInsightsPlugin } from './plugin'; describe('cost-insights', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(costInsightsPlugin).toBeDefined(); }); }); diff --git a/plugins/cost-insights/src/plugin.ts b/plugins/cost-insights/src/plugin.ts index 7006d5bf5d..29e345532d 100644 --- a/plugins/cost-insights/src/plugin.ts +++ b/plugins/cost-insights/src/plugin.ts @@ -14,10 +14,14 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import { CostInsightsPage } from './components/CostInsightsPage'; -import { ProjectGrowthInstructionsPage } from './components/ProjectGrowthInstructionsPage'; -import { LabelDataflowInstructionsPage } from './components/LabelDataflowInstructionsPage'; +import { + createPlugin, + createRouteRef, + createRoutableExtension, +} from '@backstage/core'; +import { CostInsightsPage as CostInsightsPageComponent } from './components/CostInsightsPage'; +import { ProjectGrowthInstructionsPage as ProjectGrowthInstructionsPageComponent } from './components/ProjectGrowthInstructionsPage'; +import { LabelDataflowInstructionsPage as LabelDataflowInstructionsPageComponent } from './components/LabelDataflowInstructionsPage'; export const rootRouteRef = createRouteRef({ path: '/cost-insights', @@ -34,12 +38,51 @@ export const unlabeledDataflowAlertRef = createRouteRef({ title: 'Labeling Dataflow Jobs', }); -export const plugin = createPlugin({ +export const costInsightsPlugin = createPlugin({ id: 'cost-insights', register({ router, featureFlags }) { - router.addRoute(rootRouteRef, CostInsightsPage); - router.addRoute(projectGrowthAlertRef, ProjectGrowthInstructionsPage); - router.addRoute(unlabeledDataflowAlertRef, LabelDataflowInstructionsPage); + router.addRoute(rootRouteRef, CostInsightsPageComponent); + router.addRoute( + projectGrowthAlertRef, + ProjectGrowthInstructionsPageComponent, + ); + router.addRoute( + unlabeledDataflowAlertRef, + LabelDataflowInstructionsPageComponent, + ); featureFlags.register('cost-insights-currencies'); }, + routes: { + root: rootRouteRef, + growthAlerts: projectGrowthAlertRef, + unlabeledDataflowAlerts: unlabeledDataflowAlertRef, + }, }); + +export const CostInsightsPage = costInsightsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/CostInsightsPage').then(m => m.CostInsightsPage), + mountPoint: rootRouteRef, + }), +); + +export const CostInsightsProjectGrowthInstructionsPage = costInsightsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ProjectGrowthInstructionsPage').then( + m => m.ProjectGrowthInstructionsPage, + ), + mountPoint: projectGrowthAlertRef, + }), +); + +export const CostInsightsLabelDataflowInstructionsPage = costInsightsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/LabelDataflowInstructionsPage').then( + m => m.LabelDataflowInstructionsPage, + ), + mountPoint: unlabeledDataflowAlertRef, + }), +); diff --git a/plugins/explore/CHANGELOG.md b/plugins/explore/CHANGELOG.md index 0d2daf37b5..b0334effe1 100644 --- a/plugins/explore/CHANGELOG.md +++ b/plugins/explore/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-explore +## 0.2.5 + +### Patch Changes + +- 0fe8ff5be: Catch catalog errors and display to user +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.2.4 ### Patch Changes diff --git a/plugins/explore/package.json b/plugins/explore/package.json index e9d21cc221..497e108b6f 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.2.4", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", - "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/plugin-explore-react": "^0.0.2", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", @@ -46,8 +46,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx index 4846565bdd..a52b0d8f2c 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -90,4 +90,17 @@ describe('', () => { expect(getByText('No domains to display')).toBeInTheDocument(), ); }); + + it('renders a friendly error if it cannot collect domains', async () => { + const catalogError = new Error('Network timeout'); + catalogApi.getEntities.mockRejectedValueOnce(catalogError); + + const { getByText } = render(, { + wrapper: Wrapper, + }); + + await waitFor(() => + expect(getByText(/Could not load domains/)).toBeInTheDocument(), + ); + }); }); diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx index ca00c525c9..182adf70ea 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx @@ -21,6 +21,7 @@ import { Progress, SupportButton, useApi, + WarningPanel, } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Button } from '@material-ui/core'; @@ -30,7 +31,7 @@ import { DomainCardGrid } from '../DomainCard'; export const DomainExplorerContent = () => { const catalogApi = useApi(catalogApiRef); - const { value: entities, loading } = useAsync(async () => { + const { value: entities, loading, error } = useAsync(async () => { const response = await catalogApi.getEntities({ filter: { kind: 'domain' }, }); @@ -45,7 +46,12 @@ export const DomainExplorerContent = () => { {loading && } - {!loading && (!entities || entities.length === 0) && ( + {error && ( + + {error.message} + + )} + {!loading && !error && (!entities || entities.length === 0) && ( { msw.setupDefaultHandlers(server); @@ -30,7 +45,11 @@ describe('FossaClient', () => { let client: FossaApi; beforeEach(() => { - client = new FossaClient({ discoveryApi, organizationId: '8736' }); + client = new FossaClient({ + discoveryApi, + identityApi, + organizationId: '8736', + }); }); it('should report finding summary', async () => { @@ -137,7 +156,7 @@ describe('FossaClient', () => { }); it('should skip organizationId', async () => { - client = new FossaClient({ discoveryApi }); + client = new FossaClient({ discoveryApi, identityApi }); server.use( rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { diff --git a/plugins/fossa/src/api/FossaClient.ts b/plugins/fossa/src/api/FossaClient.ts index 05a889177a..5693895901 100644 --- a/plugins/fossa/src/api/FossaClient.ts +++ b/plugins/fossa/src/api/FossaClient.ts @@ -14,28 +14,35 @@ * limitations under the License. */ -import { DiscoveryApi } from '@backstage/core'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; import fetch from 'cross-fetch'; import { FindingSummary, FossaApi } from './FossaApi'; export class FossaClient implements FossaApi { discoveryApi: DiscoveryApi; + identityApi: IdentityApi; organizationId?: string; constructor({ discoveryApi, + identityApi, organizationId, }: { discoveryApi: DiscoveryApi; + identityApi: IdentityApi; organizationId?: string; }) { this.discoveryApi = discoveryApi; + this.identityApi = identityApi; this.organizationId = organizationId; } private async callApi(path: string): Promise { const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/fossa`; - const response = await fetch(`${apiUrl}/${path}`); + const idToken = await this.identityApi.getIdToken(); + const response = await fetch(`${apiUrl}/${path}`, { + headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, + }); if (response.status === 200) { return await response.json(); } diff --git a/plugins/fossa/src/plugin.ts b/plugins/fossa/src/plugin.ts index cb1de3911f..638be4a0bf 100644 --- a/plugins/fossa/src/plugin.ts +++ b/plugins/fossa/src/plugin.ts @@ -19,6 +19,7 @@ import { createApiFactory, createPlugin, discoveryApiRef, + identityApiRef, } from '@backstage/core'; import { fossaApiRef, FossaClient } from './api'; @@ -27,10 +28,15 @@ export const fossaPlugin = createPlugin({ apis: [ createApiFactory({ api: fossaApiRef, - deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, - factory: ({ configApi, discoveryApi }) => + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => new FossaClient({ discoveryApi, + identityApi, organizationId: configApi.getOptionalString('fossa.organizationId'), }), }), diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index fced060d99..36dc403d7b 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,8 +42,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index a9bbf67a9a..8aba49fedc 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-github-actions +## 0.3.2 + +### Patch Changes + +- f5f45744e: Migrate to new composability API, exporting the plugin instance as `githubActionsPlugin`, the entity content as `EntityGithubActionsContent`, entity conditional as `isGithubActionsAvailable`, and entity cards as `EntityLatestGithubActionRunCard`, `EntityLatestGithubActionsForBranchCard`, and `EntityRecentGithubActionsRunsCard`. +- Updated dependencies [ffffea8e6] +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/integration@0.4.0 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.3.1 ### Patch Changes diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 7d05395b42..e5498563a5 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.3.1", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,9 +33,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.0.2", - "@backstage/core": "^0.6.0", - "@backstage/integration": "^0.3.2", + "@backstage/plugin-catalog-react": "^0.0.3", + "@backstage/core": "^0.6.1", + "@backstage/integration": "^0.4.0", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -51,8 +51,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/gitops-profiles/CHANGELOG.md b/plugins/gitops-profiles/CHANGELOG.md index 0fa9fa24a2..bb0834a03d 100644 --- a/plugins/gitops-profiles/CHANGELOG.md +++ b/plugins/gitops-profiles/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-gitops-profiles +## 0.2.5 + +### Patch Changes + +- accdfeb30: Migrated to new composability API, exporting the plugin instance as `gitopsProfilesPlugin` and pages as `GitopsProfilesClusterListPage`, `GitopsProfilesClusterPage`, and `GitopsProfilesCreatePage`. +- Updated dependencies [b51ee6ece] + - @backstage/core@0.6.1 + ## 0.2.4 ### Patch Changes diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index c25628fa62..251185b799 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-gitops-profiles", - "version": "0.2.4", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,8 +43,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 9293a07dd7..307cef0dee 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -44,8 +44,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index 2199c58949..26c838d97a 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-jenkins +## 0.3.9 + +### Patch Changes + +- 53348f0af: Improve display of duration in latest build card +- 025c0c7bf: Migrate to new composability API, exporting the plugin instance as `jenkinsPlugin`, the entity content as `EntityJenkinsContent`, the entity conditional as `isJenkinsAvailable`, and the entity card as `EntityLatestJenkinsRunCard`. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.3.8 ### Patch Changes diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 5d59d2e5ab..fb3e719858 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.3.8", + "version": "0.3.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,8 +32,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", - "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -48,8 +48,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/jenkins/src/components/Cards/Cards.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx index 447afff266..6fc544bcea 100644 --- a/plugins/jenkins/src/components/Cards/Cards.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -40,13 +40,13 @@ const WidgetContent = ({ const classes = useStyles(); if (loading || !latestRun) return ; const displayDate = DateTime.fromMillis(latestRun.timestamp).toRelative(); - // TODO This works, but hard codes as minutes. Would prefer something smarter/relative. - const durationInMinutes = Math.round( - Duration.fromMillis(latestRun.duration).as('minutes'), - ); - const displayDuration = `${durationInMinutes} minute${ - durationInMinutes > 1 ? 's' : '' - }`; + const displayDuration = + (latestRun.building ? 'Running for ' : '') + + DateTime.local() + .minus(Duration.fromMillis(latestRun.duration)) + .toRelative({ locale: 'en' }) + ?.replace(' ago', ''); + return ( { const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`; + const idToken = await this.identityApi.getIdToken(); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', + ...(idToken && { Authorization: `Bearer ${idToken}` }), }, body: JSON.stringify(requestBody), }); diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts index af29df3dcc..24f51fe64a 100644 --- a/plugins/kubernetes/src/plugin.ts +++ b/plugins/kubernetes/src/plugin.ts @@ -18,6 +18,7 @@ import { createPlugin, createRouteRef, discoveryApiRef, + identityApiRef, googleAuthApiRef, createRoutableExtension, } from '@backstage/core'; @@ -36,9 +37,9 @@ export const kubernetesPlugin = createPlugin({ apis: [ createApiFactory({ api: kubernetesApiRef, - deps: { discoveryApi: discoveryApiRef }, - factory: ({ discoveryApi }) => - new KubernetesBackendClient({ discoveryApi }), + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new KubernetesBackendClient({ discoveryApi, identityApi }), }), createApiFactory({ api: kubernetesAuthProvidersApiRef, diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md index 8fec8887c3..fab61283d9 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-lighthouse +## 0.2.10 + +### Patch Changes + +- f5e564cd6: Improve display of error messages +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.2.9 ### Patch Changes diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 07e1ad8475..064e260389 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.2.9", + "version": "0.2.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,8 +33,8 @@ "dependencies": { "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@backstage/core": "^0.6.0", - "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,8 +47,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/newrelic/CHANGELOG.md b/plugins/newrelic/CHANGELOG.md index 6eae73bc86..ab8ad1c289 100644 --- a/plugins/newrelic/CHANGELOG.md +++ b/plugins/newrelic/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-newrelic +## 0.2.5 + +### Patch Changes + +- c5ab91ce3: Migrate to new composability API, exporting the plugin instance as `newRelicPlugin`, and the root page as `NewRelicPage`. +- Updated dependencies [b51ee6ece] + - @backstage/core@0.6.1 + ## 0.2.4 ### Patch Changes diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index ccd43c42c8..6ab18a9cf5 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-newrelic", - "version": "0.2.4", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,8 +42,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/org/CHANGELOG.md b/plugins/org/CHANGELOG.md index 6101685e1d..898705cdc4 100644 --- a/plugins/org/CHANGELOG.md +++ b/plugins/org/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-org +## 0.3.6 + +### Patch Changes + +- 14aef4b94: Visual updates to User and Group pages +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.3.5 ### Patch Changes diff --git a/plugins/org/package.json b/plugins/org/package.json index 0491554f77..4e12ad8837 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-org", - "version": "0.3.5", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", - "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -34,8 +34,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx index 2f0f1e2e16..9a72606c16 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -21,8 +21,21 @@ import { RELATION_PARENT_OF, } from '@backstage/catalog-model'; import { Avatar, InfoCard } from '@backstage/core'; -import { useEntity, entityRouteParams } from '@backstage/plugin-catalog-react'; -import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; +import { + getEntityRelations, + entityRouteParams, + useEntity, +} from '@backstage/plugin-catalog-react'; +import { + Box, + Grid, + Link, + List, + ListItem, + ListItemIcon, + ListItemText, + Tooltip, +} from '@material-ui/core'; import AccountTreeIcon from '@material-ui/icons/AccountTree'; import EmailIcon from '@material-ui/icons/Email'; import GroupIcon from '@material-ui/icons/Group'; @@ -80,9 +93,9 @@ export const GroupProfileCard = ({ ?.map(groupItem => groupItem.target.name) .toString(); - const childrens = group?.relations - ?.filter(r => r.type === RELATION_PARENT_OF) - ?.map(groupItem => groupItem.target.name); + const childRelations = getEntityRelations(group, RELATION_PARENT_OF, { + kind: 'group', + }); const displayName = profile?.displayName ?? name; @@ -96,61 +109,51 @@ export const GroupProfileCard = ({ > - - - + - {profile?.email && ( - - - - - - - - {profile.email} - - - - )} - {parent ? ( - - - - - - + + {profile?.email && ( + + + + + + + {profile.email} + + )} + {parent ? ( + + + + + + + - - - - ) : null} - {childrens?.length ? ( - - - - - - - {childrens.map((children, index) => ( +
+ + ) : null} + {childRelations?.length ? ( + + + + + + + + {childRelations.map((children, index) => ( ))} - - - - ) : null} + + + ) : null} +
diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx index 1cf7c968b3..a4969f615b 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -19,8 +19,17 @@ import { UserEntity, } from '@backstage/catalog-model'; import { Avatar, InfoCard } from '@backstage/core'; -import { useEntity, entityRouteParams } from '@backstage/plugin-catalog-react'; -import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; +import { entityRouteParams, useEntity } from '@backstage/plugin-catalog-react'; +import { + Box, + Grid, + Link, + List, + ListItem, + ListItemIcon, + ListItemText, + Tooltip, +} from '@material-ui/core'; import EmailIcon from '@material-ui/icons/Email'; import GroupIcon from '@material-ui/icons/Group'; import PersonIcon from '@material-ui/icons/Person'; @@ -81,40 +90,35 @@ export const UserProfileCard = ({ return User not found; } + const emailHref = profile?.email ? `mailto:${profile.email}` : ''; + return ( } variant={variant}> - - - - - + + + - - {profile?.email && ( - - - - - - - {profile.email} - - - - )} - - - - - - + + + {profile?.email && ( + + + + + + {profile.email} + + + )} + + + + + + + + {groupNames.map((groupName, index) => ( ))} - - - + + + diff --git a/plugins/pagerduty/CHANGELOG.md b/plugins/pagerduty/CHANGELOG.md index eb00ce36be..3fe8e6db70 100644 --- a/plugins/pagerduty/CHANGELOG.md +++ b/plugins/pagerduty/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-pagerduty +## 0.2.8 + +### Patch Changes + +- 29a138636: Use the Luxon Date Library to follow the recommendations of ADR010. +- b288a291e: Migrated to new composability API, exporting the plugin instance as `pagerDutyPlugin`, entity card as `EntityPagerDutyCard`, and entity conditional as `isPagerDutyAvailable`. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.2.7 ### Patch Changes diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index afd41dcc46..c430075b95 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-pagerduty", - "version": "0.2.7", + "version": "0.2.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,14 +31,14 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.0.2", - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "classnames": "^2.2.6", - "date-fns": "^2.15.0", + "luxon": "1.25.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", @@ -46,16 +46,16 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", "msw": "^0.21.2", - "node-fetch": "^2.6.1", - "cross-fetch": "^3.0.6" + "node-fetch": "^2.6.1" }, "files": [ "dist" diff --git a/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx b/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx index 860623dcd4..bf0d5d2587 100644 --- a/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx +++ b/plugins/pagerduty/src/components/Incident/IncidentListItem.tsx @@ -27,7 +27,7 @@ import { Typography, } from '@material-ui/core'; import { StatusError, StatusWarning } from '@backstage/core'; -import { formatDistanceToNowStrict } from 'date-fns'; +import { DateTime, Duration } from 'luxon'; import { Incident } from '../types'; import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser'; @@ -53,8 +53,12 @@ type Props = { export const IncidentListItem = ({ incident }: Props) => { const classes = useStyles(); + const duration = + new Date().getTime() - new Date(incident.created_at).getTime(); + const createdAt = DateTime.local() + .minus(Duration.fromMillis(duration)) + .toRelative({ locale: 'en' }); const user = incident.assignments[0]?.assignee; - const createdAt = formatDistanceToNowStrict(new Date(incident.created_at)); return ( @@ -77,7 +81,7 @@ export const IncidentListItem = ({ incident }: Props) => { }} secondary={ - Created {createdAt} ago and assigned to{' '} + Created {createdAt} and assigned to{' '} { @@ -53,7 +58,10 @@ export class RollbarClient implements RollbarApi { private async get(path: string): Promise { const url = `${await this.discoveryApi.getBaseUrl('rollbar')}${path}`; - const response = await fetch(url); + const idToken = await this.identityApi.getIdToken(); + const response = await fetch(url, { + headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, + }); if (!response.ok) { const payload = await response.text(); diff --git a/plugins/rollbar/src/plugin.ts b/plugins/rollbar/src/plugin.ts index 216713b3fc..dc0ffa259a 100644 --- a/plugins/rollbar/src/plugin.ts +++ b/plugins/rollbar/src/plugin.ts @@ -20,6 +20,7 @@ import { createRoutableExtension, createRouteRef, discoveryApiRef, + identityApiRef, } from '@backstage/core'; import { rollbarApiRef } from './api/RollbarApi'; import { RollbarClient } from './api/RollbarClient'; @@ -34,8 +35,9 @@ export const rollbarPlugin = createPlugin({ apis: [ createApiFactory({ api: rollbarApiRef, - deps: { discoveryApi: discoveryApiRef }, - factory: ({ discoveryApi }) => new RollbarClient({ discoveryApi }), + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new RollbarClient({ discoveryApi, identityApi }), }), ], routes: { diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index e8526e0b56..5a3553dc79 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,28 @@ # @backstage/plugin-scaffolder-backend +## 0.7.0 + +### Minor Changes + +- 615103a63: Introduced `v2` Scaffolder REST API, which uses an implementation that is database backed, making the scaffolder instances stateless. The `createRouter` function now requires a `PluginDatabaseManager` instance to be passed in, commonly available as `database` in the plugin environment in the backend. + + This API should be considered unstable until used by the scaffolder frontend. + +### Patch Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. +- ffffea8e6: Minor updates to reflect the changes in `@backstage/integration` that made the fields `apiBaseUrl` and `apiUrl` mandatory. +- Updated dependencies [6ed2b47d6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [ffffea8e6] +- Updated dependencies [72b96e880] +- Updated dependencies [5a5163519] + - @backstage/catalog-client@0.3.6 + - @backstage/backend-common@0.5.3 + - @backstage/integration@0.4.0 + ## 0.6.0 ### Minor Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index ecce161c8e..3b25fb3881 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.6.0", + "version": "0.7.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,10 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.2", + "@backstage/backend-common": "^0.5.3", + "@backstage/catalog-client": "^0.3.6", "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.3.2", + "@backstage/integration": "^0.4.0", "@gitbeaker/core": "^28.0.2", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.0.12", @@ -61,7 +62,7 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/test-utils": "^0.1.5", + "@backstage/test-utils": "^0.1.7", "@types/fs-extra": "^9.0.1", "@types/mock-fs": "^4.13.0", "@types/supertest": "^2.0.8", diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml index 36404f088c..168daffd20 100644 --- a/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml @@ -22,6 +22,7 @@ spec: component_id: title: Name type: string + pattern: ^[a-z0-9A-Z_.-]{1,63}$ description: Unique name of the component description: title: Description diff --git a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts index 4541f03a4a..827ed0559a 100644 --- a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts +++ b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import fetch from 'cross-fetch'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { CatalogClient } from '@backstage/catalog-client'; import { ConflictError, NotFoundError, @@ -26,10 +26,12 @@ import { * A catalog client tailored for reading out entity data from the catalog. */ export class CatalogEntityClient { - private readonly discovery: PluginEndpointDiscovery; + private readonly catalogClient: CatalogClient; constructor(options: { discovery: PluginEndpointDiscovery }) { - this.discovery = options.discovery; + this.catalogClient = new CatalogClient({ + discoveryApi: options.discovery, + }); } /** @@ -37,25 +39,19 @@ export class CatalogEntityClient { * * Throws a NotFoundError or ConflictError if 0 or multiple templates are found. */ - async findTemplate(templateName: string): Promise { - const conditions = [ - 'kind=template', - `metadata.name=${encodeURIComponent(templateName)}`, - ]; - - const baseUrl = await this.discovery.getBaseUrl('catalog'); - const response = await fetch( - `${baseUrl}/entities?filter=${conditions.join(',')}`, - ); - - if (!response.ok) { - const text = await response.text(); - throw new Error( - `Request failed with ${response.status} ${response.statusText}, ${text}`, - ); - } - - const templates: TemplateEntityV1alpha1[] = await response.json(); + async findTemplate( + templateName: string, + options?: { token?: string }, + ): Promise { + const { items: templates } = (await this.catalogClient.getEntities( + { + filter: { + kind: 'template', + 'metadata.name': templateName, + }, + }, + options, + )) as { items: TemplateEntityV1alpha1[] }; if (templates.length !== 1) { if (templates.length > 1) { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts index d473656863..02865f8529 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -43,7 +43,7 @@ export class BitbucketPreparer implements PreparerBase { const targetPath = path.join(workspacePath, 'template'); const fullPathToTemplate = path.resolve( checkoutPath, - parsedGitUrl.filepath, + parsedGitUrl.filepath ?? '', ); const git = Git.fromAuth({ logger, ...this.getAuth() }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index 533aa8e005..77e6ec6d70 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -33,7 +33,7 @@ export class GithubPreparer implements PreparerBase { const targetPath = path.join(workspacePath, 'template'); const fullPathToTemplate = path.resolve( checkoutPath, - parsedGitUrl.filepath, + parsedGitUrl.filepath ?? '', ); const git = this.config.token diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index 6ed4651e4f..2de2326505 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -37,8 +37,10 @@ describe('GitLabPreparer', () => { jest.clearAllMocks(); }); const preparer = GitlabPreparer.fromConfig({ - host: 'gitlab.com', + host: '', token: 'fake-token', + apiBaseUrl: '', + baseUrl: '', }); it(`calls the clone command with the correct arguments for a repository`, async () => { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts index fa5c8c1325..e15de33ac6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -33,7 +33,7 @@ export class GitlabPreparer implements PreparerBase { const targetPath = path.join(workspacePath, 'template'); const fullPathToTemplate = path.resolve( checkoutPath, - parsedGitUrl.filepath, + parsedGitUrl.filepath ?? '', ); parsedGitUrl.git_suffix = true; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts index 61c8480efb..3826a54ddb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -57,6 +57,7 @@ describe('GitLab Publisher', () => { const publisher = await GitlabPublisher.fromConfig( { host: 'gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', token: 'fake-token', baseUrl: 'https://gitlab.hosted.com', }, @@ -105,7 +106,9 @@ describe('GitLab Publisher', () => { const publisher = await GitlabPublisher.fromConfig( { host: 'gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', token: 'fake-token', + baseUrl: 'https://gitlab.com', }, { repoVisibility: 'public' }, ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts index 30201e6dca..f5f7fc6361 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -126,6 +126,8 @@ export class Publishers implements PublisherBuilder { { token: config.getOptionalString('scaffolder.gitlab.token') ?? '', host: integration.config.host, + apiBaseUrl: ``, + baseUrl: `https://${integration.config.host}`, }, { repoVisibility }, ), diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 5821edfe8c..51425f75e8 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -133,7 +133,10 @@ export async function createRouter( }, }; - const template = await entityClient.findTemplate(templateName); + // Forward authorization from client + const template = await entityClient.findTemplate(templateName, { + token: getBearerToken(req.headers.authorization), + }); const validationResult: ValidatorResult = validate( values, @@ -295,3 +298,7 @@ export async function createRouter( return app; } + +function getBearerToken(header?: string): string | undefined { + return header?.match(/Bearer\s+(\S+)/i)?.[1]; +} diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index cd7c8fe685..2e0676e3aa 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-scaffolder +## 0.5.0 + +### Minor Changes + +- 6ed2b47d6: Include Backstage identity token in requests to backend plugins. + +### Patch Changes + +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.4.2 ### Patch Changes diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index eff16ab0f5..e75aadb03f 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { discoveryApiRef } from '@backstage/core'; +import { discoveryApiRef, identityApiRef } from '@backstage/core'; import { CatalogClient } from '@backstage/catalog-client'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { TemplateIndexPage, TemplatePage } from '../src/plugin'; @@ -30,8 +30,9 @@ createDevApp() }) .registerApi({ api: scaffolderApiRef, - deps: { discoveryApi: discoveryApiRef }, - factory: ({ discoveryApi }) => new ScaffolderApi({ discoveryApi }), + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new ScaffolderApi({ discoveryApi, identityApi }), }) .addPage({ path: '/create', diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 56beb7b10b..2b0e1a3771 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.4.2", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", - "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -52,9 +52,9 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", - "@backstage/catalog-client": "^0.3.5", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", + "@backstage/catalog-client": "^0.3.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index ca52418e92..453b1b6a7e 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef, DiscoveryApi } from '@backstage/core'; +import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', @@ -23,9 +23,14 @@ export const scaffolderApiRef = createApiRef({ export class ScaffolderApi { private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; - constructor(options: { discoveryApi: DiscoveryApi }) { + constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; } /** @@ -36,11 +41,13 @@ export class ScaffolderApi { * @param values Parameters for the template, e.g. name, description */ async scaffold(templateName: string, values: Record) { + const token = await this.identityApi.getIdToken(); const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v1/jobs`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), }, body: JSON.stringify({ templateName, values: { ...values } }), }); @@ -56,8 +63,11 @@ export class ScaffolderApi { } async getJob(jobId: string) { + const token = await this.identityApi.getIdToken(); const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); const url = `${baseUrl}/v1/job/${encodeURIComponent(jobId)}`; - return fetch(url).then(x => x.json()); + return fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }).then(x => x.json()); } } diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 732b5f3e44..ef17335b26 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -17,6 +17,8 @@ import { Button } from '@backstage/core'; import { BackstageTheme, pageTheme } from '@backstage/theme'; import { Card, + CardActions, + CardContent, Chip, makeStyles, Typography, @@ -34,18 +36,11 @@ const useStyles = makeStyles(theme => ({ props.backgroundImage, backgroundPosition: 0, }, - content: { - padding: theme.spacing(2), - }, description: { height: 175, overflow: 'hidden', textOverflow: 'ellipsis', }, - footer: { - display: 'flex', - flexDirection: 'row-reverse', - }, })); export type TemplateCardProps = { @@ -76,19 +71,19 @@ export const TemplateCard = ({ {type} {title}
-
+ {tags?.map(tag => ( ))} {description} -
- -
-
+ + + + ); }; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index f3f97be877..09df6dba1a 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -99,7 +99,7 @@ describe('TemplatePage', () => { , ); - expect(rendered.queryByText('Create a new component')).toBeInTheDocument(); + expect(rendered.queryByText('Create a New Component')).toBeInTheDocument(); expect(rendered.queryByText('React SSR Template')).toBeInTheDocument(); // await act(async () => await mutate('templates/test')); }); @@ -116,7 +116,7 @@ describe('TemplatePage', () => { , ); - expect(rendered.queryByText('Create a new component')).toBeInTheDocument(); + expect(rendered.queryByText('Create a New Component')).toBeInTheDocument(); expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument(); // Need to cleanup the promise or will timeout act(() => { @@ -141,7 +141,7 @@ describe('TemplatePage', () => { ); expect( - rendered.queryByText('Create a new component'), + rendered.queryByText('Create a New Component'), ).not.toBeInTheDocument(); expect(rendered.queryByText('This is root')).toBeInTheDocument(); }); diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index e1422d4ec1..d779f8b42c 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -149,10 +149,10 @@ export const TemplatePage = () => { return (
- Create a new component + Create a New Component } subtitle="Create new software components using standard templates" diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index ed20705185..3e884d7683 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -18,6 +18,7 @@ import { createPlugin, createApiFactory, discoveryApiRef, + identityApiRef, createRoutableExtension, } from '@backstage/core'; import { ScaffolderPage as ScaffolderPageComponent } from './components/ScaffolderPage'; @@ -30,8 +31,9 @@ export const scaffolderPlugin = createPlugin({ apis: [ createApiFactory({ api: scaffolderApiRef, - deps: { discoveryApi: discoveryApiRef }, - factory: ({ discoveryApi }) => new ScaffolderApi({ discoveryApi }), + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new ScaffolderApi({ discoveryApi, identityApi }), }), ], register({ router }) { diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md index 383cb3c484..38cbf202e1 100644 --- a/plugins/search/CHANGELOG.md +++ b/plugins/search/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-search +## 0.3.0 + +### Minor Changes + +- b3f0c3811: Migrated to new composability API, exporting the plugin instance as `searchPlugin`, and page as `SearchPage`. Due to the old router component also being called `SearchPage`, this is a breaking change. The old page component is now exported as `Router`, which can be used to maintain the old behavior. + +### Patch Changes + +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.2.7 ### Patch Changes diff --git a/plugins/search/package.json b/plugins/search/package.json index 6142e65dd3..56f863a2a6 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search", - "version": "0.2.7", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", "@backstage/catalog-model": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -44,8 +44,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md index 698f72020c..4c4d68c67b 100644 --- a/plugins/sentry/CHANGELOG.md +++ b/plugins/sentry/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-sentry +## 0.3.5 + +### Patch Changes + +- 53d3e2d62: Export the plugin instance as `sentryPlugin`. The plugin instance is still exported as `plugin` as well, but it will be removed in the future. +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.3.4 ### Patch Changes diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 06b538289b..b79b7613fe 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,8 +32,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", - "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,8 +47,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/sentry/src/extensions.tsx b/plugins/sentry/src/extensions.tsx index ea63cd472a..9a16a6a7da 100644 --- a/plugins/sentry/src/extensions.tsx +++ b/plugins/sentry/src/extensions.tsx @@ -20,9 +20,9 @@ import { } from '@backstage/core'; import { useEntity } from '@backstage/plugin-catalog-react'; import React from 'react'; -import { plugin, rootRouteRef } from './plugin'; +import { sentryPlugin, rootRouteRef } from './plugin'; -export const EntitySentryContent = plugin.provide( +export const EntitySentryContent = sentryPlugin.provide( createRoutableExtension({ mountPoint: rootRouteRef, component: () => @@ -38,7 +38,7 @@ export const EntitySentryContent = plugin.provide( }), ); -export const EntitySentryCard = plugin.provide( +export const EntitySentryCard = sentryPlugin.provide( createComponentExtension({ component: { lazy: () => diff --git a/plugins/sentry/src/index.ts b/plugins/sentry/src/index.ts index de53d7456a..2fc51715f6 100644 --- a/plugins/sentry/src/index.ts +++ b/plugins/sentry/src/index.ts @@ -16,6 +16,6 @@ export * from './api'; export * from './components'; -export { plugin } from './plugin'; +export { sentryPlugin, sentryPlugin as plugin } from './plugin'; export { EntitySentryCard, EntitySentryContent } from './extensions'; export { Router } from './components/Router'; diff --git a/plugins/sentry/src/plugin.test.ts b/plugins/sentry/src/plugin.test.ts index 8f24236586..af5feaa774 100644 --- a/plugins/sentry/src/plugin.test.ts +++ b/plugins/sentry/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { sentryPlugin } from './plugin'; describe('sentry', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(sentryPlugin).toBeDefined(); }); }); diff --git a/plugins/sentry/src/plugin.ts b/plugins/sentry/src/plugin.ts index 49ecfdb5f2..4c2a00de59 100644 --- a/plugins/sentry/src/plugin.ts +++ b/plugins/sentry/src/plugin.ts @@ -28,7 +28,7 @@ export const rootRouteRef = createRouteRef({ title: 'Sentry', }); -export const plugin = createPlugin({ +export const sentryPlugin = createPlugin({ id: 'sentry', apis: [ createApiFactory({ diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index c422f9d72e..11306bb5cd 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-sonarqube +## 0.1.11 + +### Patch Changes + +- Updated dependencies [19d354c78] +- Updated dependencies [b51ee6ece] + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.1.10 ### Patch Changes diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index a31ee320f2..e8236fbfd6 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.10", + "version": "0.1.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,8 +33,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.0.2", - "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.3", + "@backstage/core": "^0.6.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -48,8 +48,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index 49a5f8bfcd..3c4e3a0706 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-tech-radar +## 0.3.5 + +### Patch Changes + +- 804502a5c: Migrated to new composability API, exporting the plugin instance as `techRadarPlugin` and the page as `TechRadarPage`. +- Updated dependencies [b51ee6ece] + - @backstage/core@0.6.1 + ## 0.3.4 ### Patch Changes diff --git a/plugins/tech-radar/dev/index.tsx b/plugins/tech-radar/dev/index.tsx index 92eb6da567..0e9495394f 100644 --- a/plugins/tech-radar/dev/index.tsx +++ b/plugins/tech-radar/dev/index.tsx @@ -14,7 +14,14 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src'; +import { techRadarPlugin, TechRadarPage } from '../src'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerPlugin(techRadarPlugin) + .addPage({ + title: 'Tech Radar', + element: , + }) + .render(); diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index a5c9f7bb53..7a7c886372 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-radar", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -44,8 +44,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/tech-radar/src/index.ts b/plugins/tech-radar/src/index.ts index d7b4921e9a..b13e0fbeed 100644 --- a/plugins/tech-radar/src/index.ts +++ b/plugins/tech-radar/src/index.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + techRadarPlugin, + techRadarPlugin as plugin, + TechRadarPage, +} from './plugin'; export { RadarPage as Router } from './components/RadarPage'; diff --git a/plugins/tech-radar/src/plugin.test.ts b/plugins/tech-radar/src/plugin.test.ts index 6ae65f8271..f3f2fdbd77 100644 --- a/plugins/tech-radar/src/plugin.test.ts +++ b/plugins/tech-radar/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { techRadarPlugin } from './plugin'; describe('tech-radar', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(techRadarPlugin).toBeDefined(); }); }); diff --git a/plugins/tech-radar/src/plugin.ts b/plugins/tech-radar/src/plugin.ts index d8dc41af2e..63af1f390f 100644 --- a/plugins/tech-radar/src/plugin.ts +++ b/plugins/tech-radar/src/plugin.ts @@ -14,8 +14,26 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; +import { + createPlugin, + createRouteRef, + createRoutableExtension, +} from '@backstage/core'; -export const plugin = createPlugin({ - id: 'tech-radar', +const rootRouteRef = createRouteRef({ + title: 'Tech Radar', }); + +export const techRadarPlugin = createPlugin({ + id: 'tech-radar', + routes: { + root: rootRouteRef, + }, +}); + +export const TechRadarPage = techRadarPlugin.provide( + createRoutableExtension({ + component: () => import('./components/RadarPage').then(m => m.RadarPage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index 28ba4e032c..50925cec5f 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-techdocs-backend +## 0.6.0 + +### Minor Changes + +- 08142b256: URL Preparer will now use proper etag based caching introduced in https://github.com/backstage/backstage/pull/4120. Previously, builds used to be cached for 30 minutes. + +### Patch Changes + +- 08142b256: TechDocs will throw warning in backend logs when legacy git preparer or dir preparer is used to preparer docs. Migrate to URL Preparer by updating `backstage.io/techdocs-ref` annotation to be prefixed with `url:`. + Detailed docs are here https://backstage.io/docs/features/techdocs/how-to-guides#how-to-use-url-reader-in-techdocs-prepare-step + See benefits and reason for doing so https://github.com/backstage/backstage/issues/4409 +- Updated dependencies [77ad0003a] +- Updated dependencies [ffffea8e6] +- Updated dependencies [82b2c11b6] +- Updated dependencies [965e200c6] +- Updated dependencies [5a5163519] +- Updated dependencies [08142b256] +- Updated dependencies [08142b256] + - @backstage/techdocs-common@0.4.0 + - @backstage/backend-common@0.5.3 + ## 0.5.5 ### Patch Changes diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 36e5bd3bcd..2d4d1309c0 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.5.5", + "version": "0.6.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.2", + "@backstage/backend-common": "^0.5.3", "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@backstage/techdocs-common": "^0.3.7", + "@backstage/techdocs-common": "^0.4.0", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", diff --git a/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.test.ts b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.test.ts index 87de9d1d02..54ea860fb9 100644 --- a/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.test.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.test.ts @@ -17,11 +17,11 @@ import { BuildMetadataStorage } from './BuildMetadataStorage'; describe('BuildMetadataStorage', () => { it('should return build timestamp', () => { - const newMetadataStorage = new BuildMetadataStorage('123abc'); - newMetadataStorage.storeBuildTimestamp(); + const newMetadataStorage = new BuildMetadataStorage('entityID123abc'); + newMetadataStorage.setEtag('etag123abc'); - const timestamp = newMetadataStorage.getTimestamp(); + const timestamp = newMetadataStorage.getEtag(); - expect(timestamp).toBeLessThanOrEqual(Date.now()); + expect(timestamp).toBe('etag123abc'); }); }); diff --git a/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.ts b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.ts index d6a19764d9..a1bb918fae 100644 --- a/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/BuildMetadataStorage.ts @@ -14,10 +14,15 @@ * limitations under the License. */ type buildInfo = { - // uid: timestamp - [key: string]: number; + // Entity uid: etag + [key: string]: string; }; +// TODO: Build info should be part of TechDocs storage, inside `techdocs_metadata.json` +// instead of in-memory storage of the Backstage instance. +// In case of multi-region Backstage deployments, or even using multiple Kubernetes pods, +// if each instance creates its separate build info in-memory, it will result in duplicate +// builds per instance. Also if the pod restarts, all the sites will have to be re-built. const builds = {} as buildInfo; /** @@ -34,11 +39,11 @@ export class BuildMetadataStorage { this.builds = builds; } - storeBuildTimestamp() { - this.builds[this.entityUid] = Date.now(); + setEtag(etag: string): void { + this.builds[this.entityUid] = etag; } - getTimestamp() { + getEtag(): string | undefined { return this.builds[this.entityUid]; } } diff --git a/plugins/techdocs-backend/src/DocsBuilder/builder.ts b/plugins/techdocs-backend/src/DocsBuilder/builder.ts index 33d3120c47..8c54a2b1e4 100644 --- a/plugins/techdocs-backend/src/DocsBuilder/builder.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/builder.ts @@ -13,22 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { NotModifiedError } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import { + GeneratorBase, + GeneratorBuilder, + getLocationForEntity, + PreparerBase, + PreparerBuilder, + PublisherBase, + UrlPreparer, +} from '@backstage/techdocs-common'; +import Docker from 'dockerode'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; -import Docker from 'dockerode'; import { Logger } from 'winston'; -import { Entity } from '@backstage/catalog-model'; -import { Config } from '@backstage/config'; -import { - PreparerBuilder, - PublisherBase, - GeneratorBuilder, - PreparerBase, - GeneratorBase, - getLocationForEntity, - getLastCommitTimestamp, -} from '@backstage/techdocs-common'; import { BuildMetadataStorage } from '.'; const getEntityId = (entity: Entity) => { @@ -44,7 +44,6 @@ type DocsBuilderArguments = { entity: Entity; logger: Logger; dockerClient: Docker; - config: Config; }; export class DocsBuilder { @@ -54,7 +53,6 @@ export class DocsBuilder { private entity: Entity; private logger: Logger; private dockerClient: Docker; - private config: Config; constructor({ preparers, @@ -63,7 +61,6 @@ export class DocsBuilder { entity, logger, dockerClient, - config, }: DocsBuilderArguments) { this.preparer = preparers.get(entity); this.generator = generators.get(entity); @@ -71,14 +68,53 @@ export class DocsBuilder { this.entity = entity; this.logger = logger; this.dockerClient = dockerClient; - this.config = config; } - public async build() { - this.logger.info(`Running preparer on entity ${getEntityId(this.entity)}`); - const preparedDir = await this.preparer.prepare(this.entity); + public async build(): Promise { + if (!this.entity.metadata.uid) { + throw new Error( + 'Trying to build documentation for entity not in service catalog', + ); + } - const parsedLocationAnnotation = getLocationForEntity(this.entity); + /** + * Prepare and cache check + */ + + // Use the in-memory storage for setting and getting etag for this entity. + const buildMetadataStorage = new BuildMetadataStorage( + this.entity.metadata.uid, + ); + + // TODO: As of now, this happens on each and every request to TechDocs. + // In a high traffic environment, this will cause a lot of requests to the source code provider. + // After Async build is implemented https://github.com/backstage/backstage/issues/3717, + // make sure to limit checking for cache invalidation to once per minute or so. + let preparedDir: string; + let etag: string; + try { + const preparerResponse = await this.preparer.prepare(this.entity, { + etag: buildMetadataStorage.getEtag(), + }); + + preparedDir = preparerResponse.preparedDir; + etag = preparerResponse.etag; + } catch (err) { + if (err instanceof NotModifiedError) { + // No need to prepare anymore since cache is valid. + return; + } + throw new Error(err.message); + } + + this.logger.info( + `TechDocs prepare step completed for entity ${getEntityId(this.entity)}.`, + ); + this.logger.debug(`Prepared files temporarily stored at ${preparedDir}`); + + /** + * Generate + */ this.logger.info(`Running generator on entity ${getEntityId(this.entity)}`); // Create a temporary directory to store the generated files in. @@ -88,7 +124,7 @@ export class DocsBuilder { const outputDir = await fs.mkdtemp( path.join(tmpdirResolvedPath, 'techdocs-tmp-'), ); - + const parsedLocationAnnotation = getLocationForEntity(this.entity); await this.generator.run({ inputDir: preparedDir, outputDir, @@ -96,69 +132,42 @@ export class DocsBuilder { parsedLocationAnnotation, }); + this.logger.debug(`Generated files temporarily stored at ${outputDir}`); + // Remove Prepared directory since it is no longer needed. + // Caveat: Can not remove prepared directory in case of git preparer since the + // local git repository is used to get etag on subsequent requests. + if (this.preparer instanceof UrlPreparer) { + this.logger.debug( + `Removing prepared directory ${preparedDir} since the site has been generated.`, + ); + try { + // Not a blocker hence no need to await this. + fs.remove(preparedDir); + } catch (error) { + this.logger.debug(`Error removing prepared directory ${error.message}`); + } + } + + /** + * Publish + */ + this.logger.info(`Running publisher on entity ${getEntityId(this.entity)}`); await this.publisher.publish({ entity: this.entity, directory: outputDir, }); - - // TODO: Remove the generated directory once published. - - if (!this.entity.metadata.uid) { - throw new Error( - 'Trying to build documentation for entity not in service catalog', - ); - } - - new BuildMetadataStorage(this.entity.metadata.uid).storeBuildTimestamp(); - } - - public async docsUpToDate() { - if (!this.entity.metadata.uid) { - throw new Error( - 'Trying to build documentation for entity not in service catalog', - ); - } - - const buildMetadataStorage = new BuildMetadataStorage( - this.entity.metadata.uid, - ); - const { type, target } = getLocationForEntity(this.entity); - - // Unless docs are stored locally - const nonAgeCheckTypes = ['dir', 'file', 'url']; - if (!nonAgeCheckTypes.includes(type)) { - const lastCommit = await getLastCommitTimestamp( - target, - this.config, - this.logger, - ); - const storageTimeStamp = buildMetadataStorage.getTimestamp(); - - // Check if documentation source is newer than what we have - if (storageTimeStamp && storageTimeStamp >= lastCommit) { - this.logger.debug( - `Docs for entity ${getEntityId(this.entity)} is up to date.`, - ); - return true; - } - } - - // Cache downloaded source files for 30 minutes. - // TODO: When urlReader/readTree supports some way to get latest commit timestamp, - // it should be used to invalidate cache. - if (type === 'url') { - const builtAt = buildMetadataStorage.getTimestamp(); - const now = Date.now(); - - if (builtAt > now - 1800000) { - return true; - } - } - this.logger.debug( - `Docs for entity ${getEntityId(this.entity)} was outdated.`, + `Removing generated directory ${outputDir} since the site has been published`, ); - return false; + try { + // Not a blocker hence no need to await this. + fs.remove(outputDir); + } catch (error) { + this.logger.debug(`Error removing generated directory ${error.message}`); + } + + // Store the latest build etag for the entity + new BuildMetadataStorage(this.entity.metadata.uid).setEtag(etag); } } diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index fe7b52ab52..ef032e9f8b 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -13,23 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Logger } from 'winston'; -import Router from 'express-promise-router'; -import express from 'express'; -import Knex from 'knex'; -import fetch from 'cross-fetch'; -import { Config } from '@backstage/config'; -import Docker from 'dockerode'; -import { - GeneratorBuilder, - PreparerBuilder, - PublisherBase, - getLocationForEntity, -} from '@backstage/techdocs-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; -import { getEntityNameFromUrlPath } from './helpers'; +import { Config } from '@backstage/config'; +import { + GeneratorBuilder, + getLocationForEntity, + PreparerBuilder, + PublisherBase, +} from '@backstage/techdocs-common'; +import fetch from 'cross-fetch'; +import Docker from 'dockerode'; +import express from 'express'; +import Router from 'express-promise-router'; +import Knex from 'knex'; +import { Logger } from 'winston'; import { DocsBuilder } from '../DocsBuilder'; +import { getEntityNameFromUrlPath } from './helpers'; type RouterOptions = { preparers: PreparerBuilder; @@ -141,13 +141,10 @@ export async function createRouter({ dockerClient, logger, entity, - config, }); switch (publisherType) { case 'local': - if (!(await docsBuilder.docsUpToDate())) { - await docsBuilder.build(); - } + await docsBuilder.build(); break; case 'awsS3': case 'azureBlobStorage': @@ -186,9 +183,11 @@ export async function createRouter({ 'Found pre-generated docs for this entity. Serving them.', ); // TODO: re-trigger build for cache invalidation. - // Compare the date modified of the requested file on storage and compare it against - // the last modified or last commit timestamp in the repository. + // Add build info in techdocs_metadata.json and compare it against + // the etag/commit in the repository. // Without this, docs will not be re-built once they have been generated. + // Although it is unconventional that anyone will face this issue - because + // if you have an external storage, you should be using CI/CD to build and publish docs. } break; default: diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index 69fe41dd32..f782dcb3ad 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/plugin-techdocs +## 0.5.6 + +### Patch Changes + +- f5e564cd6: Improve display of error messages +- 41af18227: Migrated to new composability API, exporting the plugin instance as `techdocsPlugin`, the top-level page as `TechdocsPage`, and the entity content as `EntityTechdocsContent`. +- 8f3443427: Enhance API calls to support trapping 500 errors from techdocs-backend +- Updated dependencies [77ad0003a] +- Updated dependencies [b51ee6ece] +- Updated dependencies [19d354c78] +- Updated dependencies [08142b256] +- Updated dependencies [08142b256] +- Updated dependencies [b51ee6ece] + - @backstage/techdocs-common@0.4.0 + - @backstage/test-utils@0.1.7 + - @backstage/plugin-catalog-react@0.0.3 + - @backstage/core@0.6.1 + ## 0.5.5 ### Patch Changes diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index ad9e1f7bef..afd829d94d 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.5.5", + "version": "0.5.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,11 +33,11 @@ "dependencies": { "@backstage/config": "^0.1.2", "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.0", - "@backstage/plugin-catalog-react": "^0.0.2", - "@backstage/test-utils": "^0.1.6", + "@backstage/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", + "@backstage/test-utils": "^0.1.7", "@backstage/theme": "^0.2.3", - "@backstage/techdocs-common": "^0.3.7", + "@backstage/techdocs-common": "^0.4.0", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -51,8 +51,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 88453e8efb..493fb16a28 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,8 +42,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/user-settings/src/components/SettingsPage.tsx b/plugins/user-settings/src/components/SettingsPage.tsx index 0b4a86c99d..9fb576b200 100644 --- a/plugins/user-settings/src/components/SettingsPage.tsx +++ b/plugins/user-settings/src/components/SettingsPage.tsx @@ -14,39 +14,35 @@ * limitations under the License. */ -import React, { useState } from 'react'; -import { Content, Header, HeaderTabs, Page } from '@backstage/core'; -import { General } from './General'; +import { Header, Page, TabbedLayout } from '@backstage/core'; +import React from 'react'; import { AuthProviders } from './AuthProviders'; import { FeatureFlags } from './FeatureFlags'; +import { General } from './General'; type Props = { providerSettings?: JSX.Element; }; export const SettingsPage = ({ providerSettings }: Props) => { - const [activeTab, setActiveTab] = useState(0); - const onTabChange = (index: number) => { - setActiveTab(index); - }; - - const tabs = [ - { id: 'general', label: 'General' }, - { id: 'auth-providers', label: 'Authentication Providers' }, - { id: 'feature-flags', label: 'Feature Flags' }, - ]; - - const content = [ - , - , - , - ]; - return (
- - {content[activeTab]} + + + + + + + + + + + + ); }; diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index f3d50dc675..b6744216f3 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.6.0", + "@backstage/core": "^0.6.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,8 +42,8 @@ }, "devDependencies": { "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.9", - "@backstage/test-utils": "^0.1.6", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/yarn.lock b/yarn.lock index 05282e5285..3bfd57b222 100644 --- a/yarn.lock +++ b/yarn.lock @@ -102,182 +102,6 @@ resolved "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.7.5.tgz#3a516d198fc41a1103695bd889fdd4fbbebe7f5d" integrity sha512-T1Ham9sqZKCtSowXRPaBCRy2oz3KHglqqrKiaO7lEudpP6lwH5SwXaq4qliyKzWaqd22srJHE4szdsorbFZKlw== -"@aws-crypto/crc32@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-1.0.0.tgz#6a0164fd92bb365860ba6afb5dfef449701eb8ca" - integrity sha512-wr4EyCv3ZfLH3Sg7FErV6e/cLhpk9rUP/l5322y8PRgpQsItdieaLbtE4aDOR+dxl8U7BG9FIwWXH4TleTDZ9A== - dependencies: - tslib "^1.11.1" - -"@aws-crypto/ie11-detection@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-1.0.0.tgz#d3a6af29ba7f15458f79c41d1cd8cac3925e726a" - integrity sha512-kCKVhCF1oDxFYgQrxXmIrS5oaWulkvRcPz+QBDMsUr2crbF4VGgGT6+uQhSwJFdUAQ2A//Vq+uT83eJrkzFgXA== - dependencies: - tslib "^1.11.1" - -"@aws-crypto/sha256-browser@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-1.0.0.tgz#9c34d3b829d922b2c8e077b30a60db53d6befcb1" - integrity sha512-uSufui4ZktC5lYX6bDxgBgNboxGyw9V9V+rlcNsNTxh4YPhOdCslwJMGntiWOBRGAgXhhvWi7FqnTS2SaT3cpg== - dependencies: - "@aws-crypto/ie11-detection" "^1.0.0" - "@aws-crypto/sha256-js" "^1.0.0" - "@aws-crypto/supports-web-crypto" "^1.0.0" - "@aws-sdk/types" "^1.0.0-rc.1" - "@aws-sdk/util-locate-window" "^1.0.0-rc.1" - "@aws-sdk/util-utf8-browser" "^1.0.0-rc.1" - tslib "^1.11.1" - -"@aws-crypto/sha256-js@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-1.0.0.tgz#ca788a3242a4024c386e6b9985da28f290a79ad7" - integrity sha512-89kqtFs/tdHBFHEBXZ4UXlCISswvEor3BVVOriR68Tbk1Qe1zBOZtfbSOt3CDT69z88x5uM558YW9k8I1xei5A== - dependencies: - "@aws-sdk/types" "^1.0.0-rc.1" - "@aws-sdk/util-utf8-browser" "^1.0.0-rc.1" - tslib "^1.11.1" - -"@aws-crypto/supports-web-crypto@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-1.0.0.tgz#c40901bc17ac1e875e248df16a2b47ad8bfd9a93" - integrity sha512-IHLfv+WmVH89EW4n6a5eE8/hUlz6qkWGMn/v4r5ZgzcXdTC5nolii2z3k46y01hWRiC2PPhOdeSLzMUCUMco7g== - dependencies: - tslib "^1.11.1" - -"@aws-sdk/abort-controller@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.1.0.tgz#6b1e6cfe7410ff38ff5efdfc2e16c309d7c4e233" - integrity sha512-rqE/+BBEvF4ZxT+J1hqaSDUzXTcdnyeHQ7vqEyB1UuAmMcq6nIi1rAQKqBXneaIC4WVER3K5f00NKZGLzzqnRg== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/chunked-blob-reader-native@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/chunked-blob-reader-native/-/chunked-blob-reader-native-3.1.0.tgz#49573fe4087f07894deef7a1bf184517c3fb8f24" - integrity sha512-ghBtZkhUWgy51/651l/GUR/qhdqjFR3GSCsz0B7qisrXc8ZNsd7OlXfnTfYNoySxD3XKpbcxsncytH4Hkxgi4A== - dependencies: - "@aws-sdk/util-base64-browser" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/chunked-blob-reader@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/chunked-blob-reader/-/chunked-blob-reader-3.1.0.tgz#0a17272040760ce54ee6cd5b7e56efaca81e07aa" - integrity sha512-/2fxbKwta8ZiSj59B8F3FyVRszo1/VOhpCeL16gmRRNV73rM3IqJD+xOaDdkc/sFYyBeWn/UhwgD98kxae9XsQ== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/client-organizations@^3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/client-organizations/-/client-organizations-3.2.0.tgz#f9fc15ecc61fdf31d1cc1eb5590e526b60bc83c0" - integrity sha512-zIFHW5tETCeoSkmLh227eG25B4HnngVDktXhbPmp1NJjT2pcZzL5QIXuNcPz3PTbkaDNRdB04uRdDqryK4kY/g== - dependencies: - "@aws-crypto/sha256-browser" "^1.0.0" - "@aws-crypto/sha256-js" "^1.0.0" - "@aws-sdk/config-resolver" "3.2.0" - "@aws-sdk/credential-provider-node" "3.1.0" - "@aws-sdk/fetch-http-handler" "3.2.0" - "@aws-sdk/hash-node" "3.1.0" - "@aws-sdk/invalid-dependency" "3.2.0" - "@aws-sdk/middleware-content-length" "3.2.0" - "@aws-sdk/middleware-host-header" "3.2.0" - "@aws-sdk/middleware-logger" "3.2.0" - "@aws-sdk/middleware-retry" "3.2.0" - "@aws-sdk/middleware-serde" "3.2.0" - "@aws-sdk/middleware-signing" "3.2.0" - "@aws-sdk/middleware-stack" "3.1.0" - "@aws-sdk/middleware-user-agent" "3.2.0" - "@aws-sdk/node-config-provider" "3.1.0" - "@aws-sdk/node-http-handler" "3.2.0" - "@aws-sdk/protocol-http" "3.2.0" - "@aws-sdk/smithy-client" "3.2.0" - "@aws-sdk/url-parser-browser" "3.1.0" - "@aws-sdk/url-parser-node" "3.1.0" - "@aws-sdk/util-base64-browser" "3.1.0" - "@aws-sdk/util-base64-node" "3.1.0" - "@aws-sdk/util-body-length-browser" "3.1.0" - "@aws-sdk/util-body-length-node" "3.1.0" - "@aws-sdk/util-user-agent-browser" "3.2.0" - "@aws-sdk/util-user-agent-node" "3.2.0" - "@aws-sdk/util-utf8-browser" "3.1.0" - "@aws-sdk/util-utf8-node" "3.1.0" - tslib "^2.0.0" - -"@aws-sdk/client-s3@^3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1.0.tgz#9c39f4588420696e1a4920f8315f778a7896b858" - integrity sha512-RX/eBi3mHoJGkS146tA4ScSdOwSLEkHWlETFkcqVYdH6IP5FT9CiwRqSf6aNRE+LQiZXoBxeOdvGFXg2jWg83w== - dependencies: - "@aws-crypto/sha256-browser" "^1.0.0" - "@aws-crypto/sha256-js" "^1.0.0" - "@aws-sdk/config-resolver" "3.1.0" - "@aws-sdk/credential-provider-node" "3.1.0" - "@aws-sdk/eventstream-serde-browser" "3.1.0" - "@aws-sdk/eventstream-serde-config-resolver" "3.1.0" - "@aws-sdk/eventstream-serde-node" "3.1.0" - "@aws-sdk/fetch-http-handler" "3.1.0" - "@aws-sdk/hash-blob-browser" "3.1.0" - "@aws-sdk/hash-node" "3.1.0" - "@aws-sdk/hash-stream-node" "3.1.0" - "@aws-sdk/invalid-dependency" "3.1.0" - "@aws-sdk/md5-js" "3.1.0" - "@aws-sdk/middleware-apply-body-checksum" "3.1.0" - "@aws-sdk/middleware-bucket-endpoint" "3.1.0" - "@aws-sdk/middleware-content-length" "3.1.0" - "@aws-sdk/middleware-expect-continue" "3.1.0" - "@aws-sdk/middleware-host-header" "3.1.0" - "@aws-sdk/middleware-location-constraint" "3.1.0" - "@aws-sdk/middleware-logger" "3.1.0" - "@aws-sdk/middleware-retry" "3.1.0" - "@aws-sdk/middleware-sdk-s3" "3.1.0" - "@aws-sdk/middleware-serde" "3.1.0" - "@aws-sdk/middleware-signing" "3.1.0" - "@aws-sdk/middleware-ssec" "3.1.0" - "@aws-sdk/middleware-stack" "3.1.0" - "@aws-sdk/middleware-user-agent" "3.1.0" - "@aws-sdk/node-config-provider" "3.1.0" - "@aws-sdk/node-http-handler" "3.1.0" - "@aws-sdk/protocol-http" "3.1.0" - "@aws-sdk/smithy-client" "3.1.0" - "@aws-sdk/url-parser-browser" "3.1.0" - "@aws-sdk/url-parser-node" "3.1.0" - "@aws-sdk/util-base64-browser" "3.1.0" - "@aws-sdk/util-base64-node" "3.1.0" - "@aws-sdk/util-body-length-browser" "3.1.0" - "@aws-sdk/util-body-length-node" "3.1.0" - "@aws-sdk/util-user-agent-browser" "3.1.0" - "@aws-sdk/util-user-agent-node" "3.1.0" - "@aws-sdk/util-utf8-browser" "3.1.0" - "@aws-sdk/util-utf8-node" "3.1.0" - "@aws-sdk/util-waiter" "3.1.0" - "@aws-sdk/xml-builder" "3.1.0" - fast-xml-parser "^3.16.0" - tslib "^2.0.0" - -"@aws-sdk/config-resolver@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.1.0.tgz#36987002c18884847aa1c96e0daf546b5f9caff6" - integrity sha512-/8hNlmeUPd1Ey9WqaZsydjWpDh1rcZpzOiiIhszOj3gGhUhsGsYGqP5oCwR9vMJf0Z5+o9diGT1QLMy9Rno6pw== - dependencies: - "@aws-sdk/signature-v4" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/config-resolver@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.2.0.tgz#c83de904f155282c91e0972b5053937ea1f05160" - integrity sha512-+PRjXpWq8JlN/ilO2F5sh/HAOkoCJkGnu/e8rfnND88tghVVauFPm7xTR2LHOGp1ugpUJm8lG/iEzpYP1EUcpA== - dependencies: - "@aws-sdk/signature-v4" "3.2.0" - tslib "^1.8.0" - -"@aws-sdk/credential-provider-env@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.1.0.tgz#eaa3ed04960faafd4536e9fbb051d5007985ba03" - integrity sha512-WUSrtg/on6bP6rjOPdjhFMkPZgDoFZZL2FST4y9K6TkRxcDHejgPZmP13L74RBGIOYlcflZAxE/936OpzpsAAA== - dependencies: - "@aws-sdk/property-provider" "3.1.0" - tslib "^1.8.0" - "@aws-sdk/credential-provider-env@3.3.0": version "3.3.0" resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.3.0.tgz#7930e504a7a79ab98a9fd34befc5c84b8c4df679" @@ -287,14 +111,6 @@ "@aws-sdk/types" "3.1.0" tslib "^1.8.0" -"@aws-sdk/credential-provider-imds@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.1.0.tgz#33d48753dc00bddce79d2aa8076a7cb5bf8562df" - integrity sha512-I8P0ASEjuYpcDMfU2QKlT8vE3nIo803ct4y5Q54Osh5K7+H8Y+raYK9mxxhjycriDXhcQrOe2Rfj+1ARXjdvGw== - dependencies: - "@aws-sdk/property-provider" "3.1.0" - tslib "^1.8.0" - "@aws-sdk/credential-provider-imds@3.3.0": version "3.3.0" resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.3.0.tgz#ff0cf5489853c16d23fc99d7bae425587e836c40" @@ -304,15 +120,6 @@ "@aws-sdk/types" "3.1.0" tslib "^1.8.0" -"@aws-sdk/credential-provider-ini@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.1.0.tgz#1a6cf9ab9fa1450d4472b9e371099b0c0283349b" - integrity sha512-6kPFcsnCR1tEaVQPJGY6z58XvbeVtjsdC2srYG/0y87zyWd9awGuvRN4OV6aOr24QmEVaFNyWuE5QBQJ1/7hkA== - dependencies: - "@aws-sdk/property-provider" "3.1.0" - "@aws-sdk/shared-ini-file-loader" "3.1.0" - tslib "^1.8.0" - "@aws-sdk/credential-provider-ini@3.3.0": version "3.3.0" resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.3.0.tgz#55fe8f391b72d30e650ba8bc680e82bbeacbbfe5" @@ -323,18 +130,6 @@ "@aws-sdk/types" "3.1.0" tslib "^1.8.0" -"@aws-sdk/credential-provider-node@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.1.0.tgz#89bc8803752a3e580c6f2410306c7edad6be7fa2" - integrity sha512-/HNq75cNiXzxm6F+ZeJ3awuqUofUtcr89ZnSnwB1pkicz81yrqAH8EN2dEj90eDo1MdBQHx6rhaKg4SJUaBy3Q== - dependencies: - "@aws-sdk/credential-provider-env" "3.1.0" - "@aws-sdk/credential-provider-imds" "3.1.0" - "@aws-sdk/credential-provider-ini" "3.1.0" - "@aws-sdk/credential-provider-process" "3.1.0" - "@aws-sdk/property-provider" "3.1.0" - tslib "^1.8.0" - "@aws-sdk/credential-provider-node@^3.3.0": version "3.3.0" resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.3.0.tgz#5c97323fa7b23590070d06aa7b1be8d93b2bf4be" @@ -348,16 +143,6 @@ "@aws-sdk/types" "3.1.0" tslib "^1.8.0" -"@aws-sdk/credential-provider-process@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.1.0.tgz#ff817b29a9760c463b77be3ce49375eaeb753ef3" - integrity sha512-pecJk5W9LYLk9KOwT5A3X6ECHoakQMvXL/whqDsKdXQ4c5C1pVoV67Jnp3ilMNfr6CauxU8gQs53iPS5LUHH4A== - dependencies: - "@aws-sdk/credential-provider-ini" "3.1.0" - "@aws-sdk/property-provider" "3.1.0" - "@aws-sdk/shared-ini-file-loader" "3.1.0" - tslib "^1.8.0" - "@aws-sdk/credential-provider-process@3.3.0": version "3.3.0" resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.3.0.tgz#9de0984bd6dd0f5e40cff3672d7dd19e8cd43074" @@ -369,338 +154,6 @@ "@aws-sdk/types" "3.1.0" tslib "^1.8.0" -"@aws-sdk/eventstream-marshaller@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/eventstream-marshaller/-/eventstream-marshaller-3.1.0.tgz#65a217e37abcaa162276ccb1d4487d42431d1534" - integrity sha512-ZfWK+QPB+nuKfd90ZWpkJtK4wSKYv5qzA59jP1wwEWZ2XzUyTs2FWq4rns4af2UHnd+r5+92V2r/AZUCzT9U8w== - dependencies: - "@aws-crypto/crc32" "^1.0.0" - "@aws-sdk/util-hex-encoding" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/eventstream-serde-browser@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/eventstream-serde-browser/-/eventstream-serde-browser-3.1.0.tgz#e62f745ecb5336a957fe645f6d596501051ab73a" - integrity sha512-a7P8JzJhHTFAc6sY2uBVMr8RpLap58LXOgWUEQmozFOK6Klu2NxsdzfNGJAURsc1tK6cw126fidA9UMH/NR20Q== - dependencies: - "@aws-sdk/eventstream-marshaller" "3.1.0" - "@aws-sdk/eventstream-serde-universal" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/eventstream-serde-config-resolver@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.1.0.tgz#f4f95c4770ea34b06d79ef61eb8cbd73ac8403bc" - integrity sha512-OxgQNKNKOuXAy4ID77EC7glHALGOGdDt+fDteeajEEZ+XXQ2q4maIAbQ8N44bZcy4E8D20YEesaHDAtsDiUvPA== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/eventstream-serde-node@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/eventstream-serde-node/-/eventstream-serde-node-3.1.0.tgz#23d6630385d7023d3da9c905174ee391b5d9e5d6" - integrity sha512-xn3SwHhi9UDi6gE9fQxD8j+fxRzfnMWiu688sneoGSSzZl4e5lZtrRbMGWKdtT840dp+GrEXE6BVobUelOyVHA== - dependencies: - "@aws-sdk/eventstream-marshaller" "3.1.0" - "@aws-sdk/eventstream-serde-universal" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/eventstream-serde-universal@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/eventstream-serde-universal/-/eventstream-serde-universal-3.1.0.tgz#c48f51c92711cf0ec3a8e93b466d4dde8128d936" - integrity sha512-dnrsnLqBnFddxLtFGzDE9SBNl7+xJmgsL7SI9ALqxgvJkK7MVVj9+kzCEyypNHcRCG6OUTypvY+wjgMXJAtHUg== - dependencies: - "@aws-sdk/eventstream-marshaller" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/fetch-http-handler@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.1.0.tgz#2e495cbd5a633c3a5f9935b59faa9c8c0ed8b5e2" - integrity sha512-QCYGCdQuV3XLgqvKoJD3C/GO+cEZQOazII/rPfaeVXquv8Xrn85UJ3PZ3uFCzVVmjnm1RidwseRQoYrSsjmRhQ== - dependencies: - "@aws-sdk/protocol-http" "3.1.0" - "@aws-sdk/querystring-builder" "3.1.0" - "@aws-sdk/util-base64-browser" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/fetch-http-handler@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.2.0.tgz#116bfc06b3de797d4c746962874ef00e66fda024" - integrity sha512-Y8cw7+HNhxSpYZmd78Bq46rYUUCqnRH9AgHXkLifsigt4RsEAfYhODhEUQhEhJ/zwgadvj/fIKJWKdSiCbiC1A== - dependencies: - "@aws-sdk/protocol-http" "3.2.0" - "@aws-sdk/querystring-builder" "3.1.0" - "@aws-sdk/util-base64-browser" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/hash-blob-browser@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/hash-blob-browser/-/hash-blob-browser-3.1.0.tgz#5c6548b05d155d3141b47da958be13043a96a17f" - integrity sha512-A/iLk2P/15G//YPlB9DwNPR3J3QlrmJSrHKKYLj/t1DnxiIu3DZNCZjhLmBuQpx14O1GcolmewqJRaAq1qtVcw== - dependencies: - "@aws-sdk/chunked-blob-reader" "3.1.0" - "@aws-sdk/chunked-blob-reader-native" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/hash-node@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.1.0.tgz#2d9eb6c0a5b65c179d2e60db941ba921a83e1861" - integrity sha512-zcXjzPuraq6EEEC0N2DI/ng554XiHtLsWGYK2TkW5yS6+2xXiJFny3V5wZHWXsidHOgwgML92r/pU0PeVcTCbg== - dependencies: - "@aws-sdk/util-buffer-from" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/hash-stream-node@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/hash-stream-node/-/hash-stream-node-3.1.0.tgz#05424ef3873be626091b55617fe1df135ee34dcd" - integrity sha512-GQ7EsdH9yIYIVPhtcuwpYaTS3MNq+3jo6mUF06m8YxlG58GNIVz9gG8oMy9ezP5rpIjJX9YhXKN8GgERNxfpvQ== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/invalid-dependency@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.1.0.tgz#834c4e4a34d481bd3d4467437441ab035634710d" - integrity sha512-PNKFfIC9w25MA/fV5FnjFfFpbNiLaojgS2RtT/+L5pyic2NJ3spGNlF0pMrnPPtbrFFHaOy/E7bRa0qVtNWEyw== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/invalid-dependency@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.2.0.tgz#2c2c19cbec17ff28333c68e28420db6902a4374b" - integrity sha512-8hlqE4pWo2DoM6pAqjL+A3zYwUMaxP/RCGO+0zKbZHVw2stdRDzpO+jUfzZHR+mPJ+L/2wPwhK8D9n3yBriNBQ== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/is-array-buffer@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.1.0.tgz#7ac296d6408e34083ac007630541a1cdf67387fd" - integrity sha512-wE6Am+/FKuINc/aypXiBiLAatlSyxYQ9wGGQHf2iYOX5d5bHLOVKPoRwcqSCaiaR32aRcS7R+IhgxeBy+ajsMQ== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/md5-js@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/md5-js/-/md5-js-3.1.0.tgz#ae0799f9d9eeab01f4dd72baaa6bbb63cd45ffed" - integrity sha512-QB8D7EdxjOih9NWL/NoJRkgE0RSSJTXQLuQGH9UMj66rUQfGpwvLJDL+PbvoSUJgM8+McEBGyvjLCrDCK5r+qA== - dependencies: - "@aws-sdk/util-utf8-browser" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-apply-body-checksum@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-apply-body-checksum/-/middleware-apply-body-checksum-3.1.0.tgz#85504e7e7883830ffa06954a07f57b1fdb003408" - integrity sha512-q1Jx8HhC7fQjfrCUjbdlRw5F0Vr+gwjHf4sKd+S+FYO+15+PVtukBiKfP3lEW4KKREjTv1h/qPEjhv+CK4nN7w== - dependencies: - "@aws-sdk/is-array-buffer" "3.1.0" - "@aws-sdk/protocol-http" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-bucket-endpoint@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.1.0.tgz#38fb980fe99c42ddc264db40b93f28f84b3cc1e2" - integrity sha512-/NZuSbqSNARgsZqrRZ8yDXGqF0MSdA/vhNwXAk8BVz/oav4fveJ4KiMMO7Pq5918ZWJ3v//4bSezTnKLDzKmAw== - dependencies: - "@aws-sdk/protocol-http" "3.1.0" - "@aws-sdk/util-arn-parser" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-content-length@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.1.0.tgz#af100e6f724a188bb2963372de8d40868905be51" - integrity sha512-+QGIYkAch0q3a8LrbCS21MEKlE0WgtZBOJl76M3LZtvnbvgKkAmL4oDc+HjGD1jYuNRSt+hQZgqofuBDe8fyyw== - dependencies: - "@aws-sdk/protocol-http" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-content-length@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.2.0.tgz#876943c7498e930595621395e5928fc59cf8ed68" - integrity sha512-xS7zdcRcubv7WKs5L/RG69EuuatuPxYNQT4O9epQyUaOpTHKzdVd1wnBK+otWYxI9qnCjIXwwmMyGbJXD9OHhw== - dependencies: - "@aws-sdk/protocol-http" "3.2.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-expect-continue@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.1.0.tgz#3453aeb345ea852ebeefe392f7706b7573dd3c23" - integrity sha512-NCuF3BMf5QqXv4iYr4eGnmg7BfIpFjPvjxapB5cQbzUOXIyUtlYWUu7QrsdBxCsqgOhVJ4mAiqliVsdxML5yZA== - dependencies: - "@aws-sdk/middleware-header-default" "3.1.0" - "@aws-sdk/protocol-http" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-header-default@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-header-default/-/middleware-header-default-3.1.0.tgz#0b41d113f878d95132e10a48ca4c9667c28cf325" - integrity sha512-BJFQXxkkuIkN679bqqTo9hBUuTFtO4izzSY3WPnnYYcQwYTIH/4JSifCpJWw+AjbvTZ2rA2DrWaTNGz3zuiBGA== - dependencies: - "@aws-sdk/protocol-http" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-host-header@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.1.0.tgz#61f22f7d7ce8907d0dbc817ff67ec5545012dc51" - integrity sha512-x86fAcqhtK6ObgPJHFLRaZP+cC4gI185QwxqP72balLgIsJJ6InS7441sOMPr5AZ9xgLJyPPKXouQ5zFBYjwpw== - dependencies: - "@aws-sdk/protocol-http" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-host-header@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.2.0.tgz#cf5d0868f939834fb45c4490c1118eaf25317505" - integrity sha512-URRRaqO74DSdhqYyKbyCyadJ9No17KBMmgwz2OKecdwp6/pUVNQmlxhj1cG9leWUkBuOHo6weRvWc5zXwvfy1Q== - dependencies: - "@aws-sdk/protocol-http" "3.2.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-location-constraint@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.1.0.tgz#181007815ca9167b333666eb073b131e17d86fe7" - integrity sha512-NR8us/ZVJlxg/10h+dyu6xnKofSBWTp+Ai4yvkEFBsxr9kruVqGMP/2IFXl6iZAY5uy/H+TUJzyYtd4/DUgNgg== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/middleware-logger@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.1.0.tgz#6356f6d79524493a0485bfd86f621d7a882daa1a" - integrity sha512-RYJbms7ECg1FgYmN/IyK9U9nzWZtUmt2ZPBunUqvab/ldjaXpAtJq3IYdl1E/rgbc0LSTRDjyGC4erMDOT8IJg== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/middleware-logger@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.2.0.tgz#9d4bf84fbf7a0ddc144e6c660f1b806a23a42a6e" - integrity sha512-vkex6bsYqafdvrLe4MXJDJrEfvRb5Z8z01lVlaEV2bzXFqw7WNuFVDRC80GE/YB+r2ds/n9QU4EYUSX9wewoJQ== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/middleware-retry@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.1.0.tgz#4df049c73121a83e19f5078e90a411b93a8cf7de" - integrity sha512-jNewsmLhuSHCtwpuQlsOuQ/Cig7BE4BIF7cfpWaczqYaG1F/rdTlAmb69ugg5pVby7ccj2jgA4aw+achjuzqSw== - dependencies: - "@aws-sdk/protocol-http" "3.1.0" - "@aws-sdk/service-error-classification" "3.1.0" - react-native-get-random-values "^1.4.0" - tslib "^1.8.0" - uuid "^3.0.0" - -"@aws-sdk/middleware-retry@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.2.0.tgz#72bf988d90d9b0192aa7b290fc340b9f2e57a193" - integrity sha512-roecjUD9di1kqyCewk8pGXcsiHyi8kte5Is5bo9kCPfqHhtDN9L/efOhC+rhT0N9nKplC4rZGE4aopxNhMbLtQ== - dependencies: - "@aws-sdk/protocol-http" "3.2.0" - "@aws-sdk/service-error-classification" "3.2.0" - react-native-get-random-values "^1.4.0" - tslib "^1.8.0" - uuid "^3.0.0" - -"@aws-sdk/middleware-sdk-s3@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.1.0.tgz#e602bde5b9b5abe33e9087f61cf0f0cae9157bad" - integrity sha512-qH2RBt9EdDIO/2PGl7/QyIt9C4WuY6kVfx/S1Bh26wSY9s11VU73CDanZtgNQwIiX20v8DaPlg5dp2hse7Nr7w== - dependencies: - "@aws-sdk/protocol-http" "3.1.0" - "@aws-sdk/util-arn-parser" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-serde@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.1.0.tgz#a80b96dc0008546d9e9e9bce7e9b1ec134edbc6d" - integrity sha512-vXdYlzeBoJCqe+xhlFE9J62EyREvU1MVC6p3m3QPtfXuYlQwRb8WhlFFDFrDdA7V2usBjx5aM67OZnapplyUfg== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/middleware-serde@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.2.0.tgz#aff46b0457f44738cdc542ea4bea01160d437300" - integrity sha512-dWTL5lIjosKCUDyaNtEolaGpo/KwoM1Zx4NdMUBLYBy9v0vKSnpRLPXxyM2EREVJYHTVrvm6UoW8dM+5RFiN7A== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/middleware-signing@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.1.0.tgz#d48c18f159f867ca5efe34c4993880e6820cc11e" - integrity sha512-3zNsonJ/nkBL8gUWr2v1vQ/NKIQQgSjmrxIa4xUxRcqOOO1qgZgjablxYHQlX07B6defhaOS2/p/EDmF+LfPqQ== - dependencies: - "@aws-sdk/protocol-http" "3.1.0" - "@aws-sdk/signature-v4" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-signing@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.2.0.tgz#2ad5288d6543281a7e704102f6d446acdb9efaaa" - integrity sha512-XkDu0lMkdlf95nE2aYyAFmXOzBD+noVebyB1dlJDQCZWNzGkefc+8Nez3lCgyd2+KuEyHXMmQsqZwnmygpRvLQ== - dependencies: - "@aws-sdk/protocol-http" "3.2.0" - "@aws-sdk/signature-v4" "3.2.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-ssec@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.1.0.tgz#af57795facc29673568277d1d9aa090e57d69669" - integrity sha512-RGHWxNEMyYHv3et23XjMVS+9HhJIPR+oO21h82Eur4WhA6u3EuiZ+TKuoXZ9DA5V2wVJvPg33NHi0/uu1mah5w== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/middleware-stack@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.1.0.tgz#31a69784ff31d70e1176d948f94294563b23b36e" - integrity sha512-lin0C0xPspT/orPMWWHMYG/7Z128NsSj6Khs4G6TH+2rIixXxQtHLen8H2dSPNIYXnLaxvtUDl5VuqjRt+s2Ow== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/middleware-user-agent@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.1.0.tgz#b35ce7fae8f672aa9922bc28082137fa8c2f0486" - integrity sha512-ghUD5ZhGdvPEctacO+INnQpdpQUV8N9hC9We7ftAE75PGBG/aB8qnVWYV8uKAxx8ruNAi+iU4j+4B4qLFNMx2A== - dependencies: - "@aws-sdk/protocol-http" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/middleware-user-agent@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.2.0.tgz#19e64961feb6a41721e3164538031e21858ad7ee" - integrity sha512-rME49AFlVZSwgxTLQo/5Ok012HRcg9xhSFK4oOEkPJiYyGedvsF+Qy6sz3VjQRL2SvUvoAp03Wqbxe/Keewdbw== - dependencies: - "@aws-sdk/protocol-http" "3.2.0" - tslib "^1.8.0" - -"@aws-sdk/node-config-provider@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.1.0.tgz#74b37f65faa90fa6b4acb640e0f215f50aaa4904" - integrity sha512-zbV5Va40elco6IBddbDmhMQDWazQxHAk4trWFj3MrH36rpWZzWwsRbgjgGv0CcvSy5zP6PLHjdcBPeHS/KmViQ== - dependencies: - "@aws-sdk/property-provider" "3.1.0" - "@aws-sdk/shared-ini-file-loader" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/node-http-handler@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.1.0.tgz#b7fdbc3a1928127b2eb2064177502e11324f7bb8" - integrity sha512-T1yLundJ4tPeIt9IaOk24kBNBCe54UhCXXrh/0SSZZ26DLINPV5mF22wZKxgMpvKTbtJ6vlag+DhJe/ldqJ2qQ== - dependencies: - "@aws-sdk/abort-controller" "3.1.0" - "@aws-sdk/protocol-http" "3.1.0" - "@aws-sdk/querystring-builder" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/node-http-handler@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.2.0.tgz#cf6b12b021d61933202522179e5bd5710e565afa" - integrity sha512-C6bwM9RNINF2r7wTjQ7esjwlTnEIUZkJ+hyMGwOUxc4AqHCdc1PGEFwuMjQWhiyUtvR3hd7hfPj5GWvFP/PUTQ== - dependencies: - "@aws-sdk/abort-controller" "3.1.0" - "@aws-sdk/protocol-http" "3.2.0" - "@aws-sdk/querystring-builder" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/property-provider@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.1.0.tgz#19cf1da802a09be429e26f8eafec9b4c3473f8b0" - integrity sha512-NDCI7T8ytbzlXW8axDdtjMhJYRMVbOoPupM+HwR81hT7QoWWNrpCrjFqIv/gJet+ms5i4k80CWrBJd0L0kNl/Q== - dependencies: - tslib "^1.8.0" - "@aws-sdk/property-provider@3.3.0": version "3.3.0" resolved "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.3.0.tgz#49979cb1a3e5562d51807c7403c5fd48cb9f2cdc" @@ -709,45 +162,6 @@ "@aws-sdk/types" "3.1.0" tslib "^1.8.0" -"@aws-sdk/protocol-http@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.1.0.tgz#7da0ebcf02a40a8300f3bd52f9206f25fdf1ca7f" - integrity sha512-61qInY/AESslV6ZYTAgwoB172K/H+5EiXeWnmWExOGH3vkfkkxQBYCTcATdtasP6QYTfYiePhyjJ8eUyQL3C0w== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/protocol-http@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.2.0.tgz#0d352045bae38c66a2c97611983bfb545351c4e0" - integrity sha512-7FA23ABTZ18MsQD0GcICj6dU9HRInf6l6XvtnlPUS6ZvjDbsNRqEROUtvjl8dkDDP1mFpEQuHJ5e9MO5peEm7g== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/querystring-builder@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.1.0.tgz#6cb859cfd906faa21de32641a960f080be78f0f3" - integrity sha512-rfVJWrAT+PJ5Dx0arxK5RBMfETezAjKGHxXWf7/9rcxSqoEz8s3tYhZ7/CqcOQqN89X6gJ4veQwGprraIhOZIg== - dependencies: - "@aws-sdk/util-uri-escape" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/querystring-parser@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.1.0.tgz#eec9fcbfc1c907e19909b4436f93a6c9fb063041" - integrity sha512-tqwHKLKplyv940+cR8uW6w2K3IEcGOskJ/8oP1R3RSyyidONSVEShNlpApTQlccfYriieDuEDLVCo4pT7S55kw== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/service-error-classification@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.1.0.tgz#9471f761f36ebe3efeb0791d7e2af517f87ef4a2" - integrity sha512-zUNV9Fyguto8VOhinKvzIoQxwfYMSSLO6xTKJLyTB+cDv51SX5sh+lqX6IKGhuqz2Wse1ynuoLGOnmVoe4aUpQ== - -"@aws-sdk/service-error-classification@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.2.0.tgz#286ba27368276132db89f21b79724bf95cbd8d97" - integrity sha512-ZzeSZ0HnJq5zUkA466zVEZAE1SVqYuvGzxRGTEszbMW2eWPLonJgUwiT5nDScmJQcDju044iP//2KnBvn62Rhg== - "@aws-sdk/shared-ini-file-loader@3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.1.0.tgz#b1bc7ed4e16535f20c788915060a121e457efdfb" @@ -755,205 +169,11 @@ dependencies: tslib "^1.8.0" -"@aws-sdk/signature-v4@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.1.0.tgz#b5d480af74629e86163ffe86a3f13a37f1f1b250" - integrity sha512-TFbe/Xf02a4baU1tg/S/w1A5AKaD7YHuqQg4Ak+HbRvAFe77/C/rbqWGnl66BDcukMvx13Ywd5ZkraAVSaTlzg== - dependencies: - "@aws-sdk/is-array-buffer" "3.1.0" - "@aws-sdk/util-hex-encoding" "3.1.0" - "@aws-sdk/util-uri-escape" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/signature-v4@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.2.0.tgz#9eb5c4c3f5c3d448f3929f310baa4ad3682d1ddc" - integrity sha512-Fb81gqaSnuCwO9HFYnztksyFud5X6/Ikr95X3pfrtLcHlHe/S079woWmlDUEgy7lw2X2GY94TcX9jlXuMhTfhA== - dependencies: - "@aws-sdk/is-array-buffer" "3.1.0" - "@aws-sdk/util-hex-encoding" "3.1.0" - "@aws-sdk/util-uri-escape" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/smithy-client@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.1.0.tgz#dc60223b9af1e99d076871e8138ea9b6ecbf737b" - integrity sha512-l/xCyQ0xLvIX/VZDYC3CGAphXtV7/t1gqfxfATbx1TD3E23PS0XvaV8JAmMWF+Q+rShk+otuYpdaRjGrygTK5w== - dependencies: - "@aws-sdk/middleware-stack" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/smithy-client@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.2.0.tgz#ba93d125f83f385ce6a4600e47339bf036fc7d0a" - integrity sha512-LpBGQ5m/oQ6QkMPQl7yxvBll/NRww4GUCEwpEXw412SUbX7aVTu+CNoUmF2lg8Gwp2XM3NgOkJ9DQ94YwZ/dug== - dependencies: - "@aws-sdk/middleware-stack" "3.1.0" - tslib "^1.8.0" - "@aws-sdk/types@3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@aws-sdk/types/-/types-3.1.0.tgz#04d77c37a80b422e8123f296338d129e51f3e1fc" integrity sha512-4Az7cemXCN4Qp8EheNkZTJJqIG0dvCT2KAreJLoclcVTcEFw2rzlATUnSeia1YTRsVd6aNxD001Ug7f3vYcQkw== -"@aws-sdk/types@^1.0.0-rc.1": - version "1.0.0-rc.10" - resolved "https://registry.npmjs.org/@aws-sdk/types/-/types-1.0.0-rc.10.tgz#729127fbfac5da1a3368ffe6ec2e90acc9ad69c3" - integrity sha512-9gwhYnkTNuYZ+etCtM4T8gjpZ0SWSXbzQxY34UjSS+dt3C/UnbX0J22tMahp/9Z1yCa9pihtXrkD+nO2xn7nVQ== - -"@aws-sdk/url-parser-browser@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/url-parser-browser/-/url-parser-browser-3.1.0.tgz#8e657d9e6cbde454df3b6e597e4fa4b690fe47af" - integrity sha512-XtiorfIxhbJuU1TLp7rL7qMzNY/+9FYisFtCFcrcy7/fsvXUTjjB2u3O4J01m/T6/HVmenV+SFQvs+fYC9U7dA== - dependencies: - "@aws-sdk/querystring-parser" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/url-parser-node@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/url-parser-node/-/url-parser-node-3.1.0.tgz#f49690405195f80157997fd48f99e1e036de3a3f" - integrity sha512-pDQekV4RK2hoiDB4YkMi00oTgU8MoHg+Sve9HaLYCtTuirHMVBswxzGg6MVDWo0tM6WSFbnQRXNZtioUo7ExVA== - dependencies: - "@aws-sdk/querystring-parser" "3.1.0" - tslib "^1.8.0" - url "^0.11.0" - -"@aws-sdk/util-arn-parser@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.1.0.tgz#56c462b2ef6d7cfa098e1a22e44fb3b52da0718c" - integrity sha512-xXL/nadq5mqEw6Mrv1ghoODuyWWsAxvr+rRNgDJOav6mypgEOiLb0ybkqinrH1ogTkAYbegs+uaWxgSPBe9ZSA== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/util-base64-browser@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-base64-browser/-/util-base64-browser-3.1.0.tgz#756253a3fc1ad58c38ad28ed664b701d850d3aa9" - integrity sha512-xkodj0VnkHl1gdYI9Nl4E2Ed+atM3xBTNaedoGnmqoyosMjPRJCpU8uFBmdiF4e+GGPsXlYe9oA/hLyJFxmeSQ== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/util-base64-node@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-base64-node/-/util-base64-node-3.1.0.tgz#027a9e854d204adda56d5f43f8ef4a20532b7ac9" - integrity sha512-FEtnINw2MeD3LAtyGcofah5D8j6OjpmwNKibr7mIgosRO++iVyXe2xa6iOoptZFn5pIU0C4fkJn5o+kjBhRafA== - dependencies: - "@aws-sdk/util-buffer-from" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/util-body-length-browser@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.1.0.tgz#b68018860cd5d63c9327c7a42323c5c75cf514bf" - integrity sha512-vzKDD/p1gcA05jeLmn6+6HdOY4G6Axyp6dj1R1nVeFpPPx6KkFsNGL9/CoaRT2TGv1fHBoDXsve9JRaCxrER4Q== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/util-body-length-node@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-body-length-node/-/util-body-length-node-3.1.0.tgz#c280223066f0ce8fadb002a668a5f5e6ee4de12b" - integrity sha512-MfJoU2wFWkOmbjWDepq5bDGYZlpvtBi2Vs8ZeTcm/4+q+3L9tJ/Zb/Ofx5oeRg9VhCsAjvceQTdX+CAyP8byXA== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/util-buffer-from@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-buffer-from/-/util-buffer-from-3.1.0.tgz#a68b2e191f3737bcd85c33af5a6b0e3e9f974f0e" - integrity sha512-UeC4VKmWYgTXjNdLVHfurrdhznnoxWLUFx8xspyRd58BhSZ5vc5HiiKTPX/CGxzAP/qZG668PaoOJucwmEam4g== - dependencies: - "@aws-sdk/is-array-buffer" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/util-hex-encoding@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.1.0.tgz#937440d60333c1b3e4fbe06012dfdc65c9e297bc" - integrity sha512-MPOsUY3USCUBaqZ3ifgE9il/liVxEKsz6dYQ08pdtWRzZx2CT7kWslQeNAT565pMvktnvdLjfzBw2FwnSI6nqg== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/util-locate-window@^1.0.0-rc.1": - version "1.0.0-rc.8" - resolved "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-1.0.0-rc.8.tgz#d28175aeb9c8ad3940242e615b1503632d3be33d" - integrity sha512-TvqeA4fgmZ0A0x3K+qVj/OSWEFHGZjzpVuyXlm1EYOf7NQ9VWRlokEn1MYKuL+t7al9ZeQyi16D8Dn7DW1eidw== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/util-uri-escape@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.1.0.tgz#1e4450c8e047b542f87172407e2fe0ade7c55227" - integrity sha512-1ZcXVJpsA6uW3tDTQI+Rpawqh76fyHpFc55ST8VGyMgmCzlJzBpYG0ck1kqVRSUP7YyvkJQvHfcm+U6doL5Xkw== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/util-user-agent-browser@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.1.0.tgz#471dec78305f39ac9a05968970917b5c66e4312b" - integrity sha512-w31WYjRTXbBn9z/sRy/4IyjIyEeTOon1JvRzlKDbEWm4JXarVbiJcZKs1U3q+9fS9la5uLM1NEQCsgdI1iYERA== - dependencies: - "@aws-sdk/types" "3.1.0" - bowser "^2.11.0" - tslib "^1.8.0" - -"@aws-sdk/util-user-agent-browser@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.2.0.tgz#f6fdee45817d80a637d7bd66985f516182b6f550" - integrity sha512-ugkLsntS2wpLWCjPs49wnAGhuIAFyHYzTznp0v+Qj/uOkm0ddq4kWp+FwtMxeQMRY0eoBdfqXJAbJLz8w4xlUQ== - dependencies: - bowser "^2.11.0" - tslib "^1.8.0" - -"@aws-sdk/util-user-agent-node@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.1.0.tgz#3fa66564a5fdb0e2851554688dc507127356f941" - integrity sha512-VyyZUR4vrHyT4aLH+ufoaTxFy65K9OtoCEdH1X748HinLZY9JObCCO6lZVNr5b26fGJrbqzoAqaXWgHkmEUpoQ== - dependencies: - "@aws-sdk/node-config-provider" "3.1.0" - "@aws-sdk/types" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/util-user-agent-node@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.2.0.tgz#9190f69afdd81bacde16e253af2545a0e3f443cd" - integrity sha512-Ovyq6yc7SL0NWZWBrPKRc0D38xkMH2ulslcnukdNOitv0s8V2Ge09R4T63U0I8IJ7nrKa75Uus6A8vC8Zko3yg== - dependencies: - "@aws-sdk/node-config-provider" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/util-utf8-browser@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.1.0.tgz#7be17b545af101c320d34aace47139cf9987d796" - integrity sha512-vJP20me+Wc1RJHq+Y+gFD25aWhbQte+Qkyh3SOKQ+YvNaMcaeVwOV7b3Y3ItBuMdutHLJWmbJ2wF6dhhpy1kOA== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/util-utf8-browser@^1.0.0-rc.1": - version "1.0.0-rc.8" - resolved "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-1.0.0-rc.8.tgz#bf1f1cfed8c024f43a7c43b643fdf2b4523b5973" - integrity sha512-clncPMJ23rxCIkZ9LoUC8SowwZGxWyN2TwRb0XvW/Cv9EavkRgRCOrCpneGyC326lqtMKx36onnpaSRHxErUYw== - dependencies: - tslib "^1.8.0" - -"@aws-sdk/util-utf8-node@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-utf8-node/-/util-utf8-node-3.1.0.tgz#fd7e506b5fdf404fbbdd9bc46429afeea8114b25" - integrity sha512-lrBLkROMh9kTjHOguusqLvTX5+5O5CVpAGeISZlW6CCx2pMHtVRyE9cdNuRI8aJpyZsU12j8SoaKDUPGD+ixzw== - dependencies: - "@aws-sdk/util-buffer-from" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/util-waiter@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/util-waiter/-/util-waiter-3.1.0.tgz#e01dbd6bc903e03101152f65ab9d2e0613f08c2f" - integrity sha512-nMaE3aGCZGVQw10IUXBxIbCXkyaX8T1Fn7GvJxeFJflKcXuKFKBfvkhzimSe/hJDC6Ykn7gNuI+EY+ZoCVXqtQ== - dependencies: - "@aws-sdk/abort-controller" "3.1.0" - tslib "^1.8.0" - -"@aws-sdk/xml-builder@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.1.0.tgz#ad7113075416436d8c822199674c45fc6ef42441" - integrity sha512-F6liCbWPMbnJq8d0qgzuXwG5O7jg1hhgiG71TTn83rnc6vFzyw2o0C+ztiqSZsbAq7r2PlEfBPWVD32gTFIXXw== - dependencies: - tslib "^1.8.0" - "@azure/abort-controller@^1.0.0": version "1.0.2" resolved "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.0.2.tgz#822405c966b2aec16fb62c1b19d37eaccf231995" @@ -2546,7 +1766,7 @@ globals "^11.1.0" lodash "^4.17.19" -"@babel/types@7.11.5", "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.8.6", "@babel/types@^7.9.5", "@babel/types@^7.9.6": +"@babel/types@7.11.5", "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.8.6", "@babel/types@^7.9.6": version "7.11.5" resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q== @@ -2607,7 +1827,7 @@ yup "^0.29.3" "@backstage/core@^0.3.0": - version "0.6.0" + version "0.6.1" dependencies: "@backstage/config" "^0.1.2" "@backstage/core-api" "^0.2.8" @@ -2646,13 +1866,13 @@ zen-observable "^0.8.15" "@backstage/plugin-catalog@^0.2.0": - version "0.3.0" + version "0.3.1" dependencies: - "@backstage/catalog-client" "^0.3.5" + "@backstage/catalog-client" "^0.3.6" "@backstage/catalog-model" "^0.7.1" - "@backstage/core" "^0.6.0" - "@backstage/plugin-catalog-react" "^0.0.2" - "@backstage/plugin-scaffolder" "^0.4.2" + "@backstage/core" "^0.6.1" + "@backstage/plugin-catalog-react" "^0.0.3" + "@backstage/plugin-scaffolder" "^0.5.0" "@backstage/theme" "^0.2.3" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" @@ -2660,7 +1880,6 @@ "@types/react" "^16.9" classnames "^2.2.6" git-url-parse "^11.4.4" - moment "^2.26.0" react "^16.13.1" react-dom "^16.13.1" react-helmet "6.1.0" @@ -2670,13 +1889,13 @@ swr "^0.3.0" "@backstage/plugin-catalog@^0.2.1": - version "0.3.0" + version "0.3.1" dependencies: - "@backstage/catalog-client" "^0.3.5" + "@backstage/catalog-client" "^0.3.6" "@backstage/catalog-model" "^0.7.1" - "@backstage/core" "^0.6.0" - "@backstage/plugin-catalog-react" "^0.0.2" - "@backstage/plugin-scaffolder" "^0.4.2" + "@backstage/core" "^0.6.1" + "@backstage/plugin-catalog-react" "^0.0.3" + "@backstage/plugin-scaffolder" "^0.5.0" "@backstage/theme" "^0.2.3" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" @@ -2684,7 +1903,6 @@ "@types/react" "^16.9" classnames "^2.2.6" git-url-parse "^11.4.4" - moment "^2.26.0" react "^16.13.1" react-dom "^16.13.1" react-helmet "6.1.0" @@ -3340,30 +2558,30 @@ tslib "~2.0.1" "@graphql-codegen/typescript@^1.17.7", "@graphql-codegen/typescript@^1.18.1": - version "1.18.1" - resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.18.1.tgz#3d730472a01f18aea6331046f4ebfe3d91326801" - integrity sha512-Ee37NutKmaNrgAo2d5mv42RqPd8jJ6zyUKAH669Gbv0dFn2EK3sdC9PYQC9gXptv+H/eQn2gYgaa2nVpEPAIzg== + version "1.20.2" + resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.20.2.tgz#223327a8aea929e772f481e264c5aa3a36707fcb" + integrity sha512-D/DMUz4O2UEoFucUVu2K2xoaMPAn68BwYGnCAKnSDqtFKsOEqmTjHFwcgyEnpucQ5xFeG0pKGMb1SlS2Go9J8Q== dependencies: "@graphql-codegen/plugin-helpers" "^1.18.2" - "@graphql-codegen/visitor-plugin-common" "^1.17.20" + "@graphql-codegen/visitor-plugin-common" "^1.18.2" auto-bind "~4.0.0" - tslib "~2.0.1" + tslib "~2.1.0" -"@graphql-codegen/visitor-plugin-common@^1.17.20": - version "1.17.20" - resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.17.20.tgz#cff95cdd49cef270b3811fdb141a412ffe2bdfd7" - integrity sha512-buIpcNNyTqVubknancX8m9jARCZsUA5eKuskg+CylWKL/8CSaD2Tiq7CfbbNO10o7XIgRrPtJMl1c9hQ6N4ytw== +"@graphql-codegen/visitor-plugin-common@^1.17.20", "@graphql-codegen/visitor-plugin-common@^1.18.2": + version "1.18.2" + resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.18.2.tgz#1a30bd33f011b4fb976e0f8462d160126db875ea" + integrity sha512-A8yBJGq7A7gxaVVXK4QXwV1ZpzZ64fH7U7JTGeq86o3jA7QNV2rmCRXCY0JttS2fu+olV18NjsWRwAXuAb1g9A== dependencies: "@graphql-codegen/plugin-helpers" "^1.18.2" "@graphql-tools/optimize" "^1.0.1" "@graphql-tools/relay-operation-optimizer" "^6" array.prototype.flatmap "^1.2.4" auto-bind "~4.0.0" - dependency-graph "^0.9.0" + dependency-graph "^0.10.0" graphql-tag "^2.11.0" parse-filepath "^1.0.2" pascal-case "^3.1.1" - tslib "~2.0.1" + tslib "~2.1.0" "@graphql-modules/core@^0.7.17": version "0.7.17" @@ -5405,16 +4623,16 @@ integrity sha512-In1q0tIiqTYKAGe3KOHDcFDdZRFISyQeSeipeTHGfki23ebHRZcjxvqj5SSdBkw65D4VpSREMi0s9i5iJiMcTw== "@storybook/addon-actions@^6.1.11": - version "6.1.11" - resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.1.11.tgz#73e91cc95c45ea477cfd4f3603f6b95f5829eab6" - integrity sha512-J44XLx2G732OG7Az79Cpk5UlI5SyXHeQqdykwT/4IEQXSBXAYWSTIJJjpJdcjR/D+zpklab1QDSiWxCrKbe81A== + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.1.17.tgz#9d32336284738cefa69b99acafa4b132d5533600" + integrity sha512-4hyAvmjnI4C1ZQ7/t21jKKXE0jO1zAk310BkYin0NJf77Qi0tUE1DNOwirJY/xzRih36wWi1V79c/ZOJNsLv9Q== dependencies: - "@storybook/addons" "6.1.11" - "@storybook/api" "6.1.11" - "@storybook/client-api" "6.1.11" - "@storybook/components" "6.1.11" - "@storybook/core-events" "6.1.11" - "@storybook/theming" "6.1.11" + "@storybook/addons" "6.1.17" + "@storybook/api" "6.1.17" + "@storybook/client-api" "6.1.17" + "@storybook/components" "6.1.17" + "@storybook/core-events" "6.1.17" + "@storybook/theming" "6.1.17" core-js "^3.0.1" fast-deep-equal "^3.1.1" global "^4.3.2" @@ -5480,7 +4698,7 @@ global "^4.3.2" regenerator-runtime "^0.13.7" -"@storybook/addons@6.1.15", "@storybook/addons@^6.1.11": +"@storybook/addons@6.1.15": version "6.1.15" resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.15.tgz#09eb8d962f58bd20b4ac2f83b515831c83226352" integrity sha512-ENyHapLFOG93VaoQXPX8O3IWjLRyVBox9C9P20LMruKX/SfXAXx20qsoAWKKPGssopyOin17aoQX9pj+lFmCZQ== @@ -5495,6 +4713,21 @@ global "^4.3.2" regenerator-runtime "^0.13.7" +"@storybook/addons@6.1.17", "@storybook/addons@^6.1.11": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.17.tgz#ab0666446acb9fc19c94d7204dc9aafdefb6c7c2" + integrity sha512-3upDPJPzUkls2V3Fozzg+JOcv138bF90pbdRe9YSNu37QvRIL+iQODY7oFygMl+kqjG2F1FGw5EvxAV1mnlwCw== + dependencies: + "@storybook/api" "6.1.17" + "@storybook/channels" "6.1.17" + "@storybook/client-logger" "6.1.17" + "@storybook/core-events" "6.1.17" + "@storybook/router" "6.1.17" + "@storybook/theming" "6.1.17" + core-js "^3.0.1" + global "^4.3.2" + regenerator-runtime "^0.13.7" + "@storybook/api@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/api/-/api-6.1.11.tgz#1e0b798203df823ac21184386258cf8b5f17f440" @@ -5545,18 +4778,30 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/channel-postmessage@6.1.11": - version "6.1.11" - resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.1.11.tgz#62c1079f04870dd27925bd538a2020e7380daa2e" - integrity sha512-voW4Z2SUacDOxwN2q1NEBL//8OpgvL2C5CeoG1VQyEllKM8Vg9t1Nxo2FFTJBzv5LeEX7VIJKeBoB25DYvKyng== +"@storybook/api@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/api/-/api-6.1.17.tgz#50393ce9b718063b67680212df895eceacc0c11d" + integrity sha512-sthcfuk2EQ3F5R620PBqpI4Pno3g7KQm6YPZA0DXB+LD/z61xH9ToE1gTLF4nzlE6HwghwkXOZyRwDowRdG+7A== dependencies: - "@storybook/channels" "6.1.11" - "@storybook/client-logger" "6.1.11" - "@storybook/core-events" "6.1.11" + "@reach/router" "^1.3.3" + "@storybook/channels" "6.1.17" + "@storybook/client-logger" "6.1.17" + "@storybook/core-events" "6.1.17" + "@storybook/csf" "0.0.1" + "@storybook/router" "6.1.17" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.1.17" + "@types/reach__router" "^1.3.7" core-js "^3.0.1" + fast-deep-equal "^3.1.1" global "^4.3.2" - qs "^6.6.0" + lodash "^4.17.15" + memoizerific "^1.11.3" + regenerator-runtime "^0.13.7" + store2 "^2.7.1" telejson "^5.0.2" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" "@storybook/channel-postmessage@6.1.15": version "6.1.15" @@ -5571,6 +4816,19 @@ qs "^6.6.0" telejson "^5.0.2" +"@storybook/channel-postmessage@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.1.17.tgz#309ce67c94637ec13319d4ce360a8f3742ddbaf4" + integrity sha512-2nVqxq4oZdSITqhFOnkh1rmDMjCwHuobnK5Fp3l7ftCkbmiZHMheKK9Tz4Rb803dhXvcGYs0zRS8NjKyxlOLsA== + dependencies: + "@storybook/channels" "6.1.17" + "@storybook/client-logger" "6.1.17" + "@storybook/core-events" "6.1.17" + core-js "^3.0.1" + global "^4.3.2" + qs "^6.6.0" + telejson "^5.0.2" + "@storybook/channels@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.1.11.tgz#a93a83746ad78dd40e1c056029f6d93b17bb66bc" @@ -5589,27 +4847,12 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-api@6.1.11": - version "6.1.11" - resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.1.11.tgz#d25aac484ca84a1acb01d450e756a62408f00c1a" - integrity sha512-DodJQzGCR+PYs26klvbquTjfBgkw5nvCZd3jpgWQtOrYaY/cMY1LLkVkKqrm2ENW8f7vf7tiw78RtxaXy7xeIQ== +"@storybook/channels@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.1.17.tgz#2cc89a6b9727d19c24b15fa3cb15569b469db864" + integrity sha512-MUdj0eKr/AbxevHTSXX7AsgxAz6e5O4ZxoYX5G8ggoqSXrWzws6zRFmUmmTdjpIvVmP2M1Kh4SYFAKcS/AGw9w== dependencies: - "@storybook/addons" "6.1.11" - "@storybook/channel-postmessage" "6.1.11" - "@storybook/channels" "6.1.11" - "@storybook/client-logger" "6.1.11" - "@storybook/core-events" "6.1.11" - "@storybook/csf" "0.0.1" - "@types/qs" "^6.9.0" - "@types/webpack-env" "^1.15.3" core-js "^3.0.1" - global "^4.3.2" - lodash "^4.17.15" - memoizerific "^1.11.3" - qs "^6.6.0" - regenerator-runtime "^0.13.7" - stable "^0.1.8" - store2 "^2.7.1" ts-dedent "^2.0.0" util-deprecate "^1.0.2" @@ -5637,6 +4880,30 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/client-api@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.1.17.tgz#3ced22f08a47af70ccf8929111bc44b79e9e8ec0" + integrity sha512-Loz/wdh0axgq0PS19tx0tGEFEkFWlYc6YauJGHjygYa1xX7mJ54hDoaTolySCXN1HtfZn08D847yjGSN2oIqVg== + dependencies: + "@storybook/addons" "6.1.17" + "@storybook/channel-postmessage" "6.1.17" + "@storybook/channels" "6.1.17" + "@storybook/client-logger" "6.1.17" + "@storybook/core-events" "6.1.17" + "@storybook/csf" "0.0.1" + "@types/qs" "^6.9.0" + "@types/webpack-env" "^1.15.3" + core-js "^3.0.1" + global "^4.3.2" + lodash "^4.17.15" + memoizerific "^1.11.3" + qs "^6.6.0" + regenerator-runtime "^0.13.7" + stable "^0.1.8" + store2 "^2.7.1" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/client-logger@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.1.11.tgz#5dd092e4293e5f58f7e89ddbc6eb2511b7d60954" @@ -5653,6 +4920,14 @@ core-js "^3.0.1" global "^4.3.2" +"@storybook/client-logger@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.1.17.tgz#0d89aaf824457f19bf9aa585bbcada57595e7d01" + integrity sha512-oqExrxhmws0ihB47sjdynZHd3OpUP4KWkx4udG+74lYIvBH+EZmQ9xF+UofeY3j5p1I9k8ugEcVKy0sqh1yR3w== + dependencies: + core-js "^3.0.1" + global "^4.3.2" + "@storybook/components@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/components/-/components-6.1.11.tgz#edd5db7fe43f47b5a7ab515840795a89d931512e" @@ -5705,6 +4980,32 @@ react-textarea-autosize "^8.1.1" ts-dedent "^2.0.0" +"@storybook/components@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/components/-/components-6.1.17.tgz#f92d36e370ec6039d8c7cee9ef13dda866eed3da" + integrity sha512-rIEll0UTxEKmG4IsSS5K+6DjRLVtX8J+9cg79GSAC7N1ZHUR1UQmjjJaehJa5q/NQ5H8C39acxpT4Py/BcsL2g== + dependencies: + "@popperjs/core" "^2.5.4" + "@storybook/client-logger" "6.1.17" + "@storybook/csf" "0.0.1" + "@storybook/theming" "6.1.17" + "@types/overlayscrollbars" "^1.9.0" + "@types/react-color" "^3.0.1" + "@types/react-syntax-highlighter" "11.0.4" + core-js "^3.0.1" + fast-deep-equal "^3.1.1" + global "^4.3.2" + lodash "^4.17.15" + markdown-to-jsx "^6.11.4" + memoizerific "^1.11.3" + overlayscrollbars "^1.10.2" + polished "^3.4.4" + react-color "^2.17.0" + react-popper-tooltip "^3.1.1" + react-syntax-highlighter "^13.5.0" + react-textarea-autosize "^8.1.1" + ts-dedent "^2.0.0" + "@storybook/core-events@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.1.11.tgz#d50e8ec90490f9a7180a8c8a83afb6dcfe47ed66" @@ -5719,6 +5020,13 @@ dependencies: core-js "^3.0.1" +"@storybook/core-events@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.1.17.tgz#697ed916fcb2a411bc9f8bdbfacd0eb9d394eb58" + integrity sha512-xBI7kmyROcqhYNmFv4QBjD77CzV+k/0F051YFS5WicEI4qDWPPvzaShhm96ZrGobUX3+di4pC11gqdsrFeNCEg== + dependencies: + core-js "^3.0.1" + "@storybook/core@6.1.15": version "6.1.15" resolved "https://registry.npmjs.org/@storybook/core/-/core-6.1.15.tgz#7ff8c314d3857497bf2e26c69a1fa93ef37301aa" @@ -5895,6 +5203,18 @@ memoizerific "^1.11.3" qs "^6.6.0" +"@storybook/router@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.1.17.tgz#96746878c50c6c97c7de5a1b23a9503c5d648775" + integrity sha512-wLqSOB5yLXgNyDGy008RUvjVRtVMq7lhmMRicSIxgJpkakPrMRN8n/nK7pxgQc/xDTphnS0u1nT01i97WszhCg== + dependencies: + "@reach/router" "^1.3.3" + "@types/reach__router" "^1.3.7" + core-js "^3.0.1" + global "^4.3.2" + memoizerific "^1.11.3" + qs "^6.6.0" + "@storybook/semver@^7.3.2": version "7.3.2" resolved "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz#f3b9c44a1c9a0b933c04e66d0048fcf2fa10dac0" @@ -5956,6 +5276,24 @@ resolve-from "^5.0.0" ts-dedent "^2.0.0" +"@storybook/theming@6.1.17": + version "6.1.17" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.1.17.tgz#99cc120a230c30458d833b40c806b9b4dff7b34a" + integrity sha512-LpRuY2aIh2td+qZi7E8cp2oM88LudNMmTsBT6N2/Id69u/a9qQd2cYCA9k9fAsg7rjor+wR/N695jk3SGtoFTw== + dependencies: + "@emotion/core" "^10.1.1" + "@emotion/is-prop-valid" "^0.8.6" + "@emotion/styled" "^10.0.23" + "@storybook/client-logger" "6.1.17" + core-js "^3.0.1" + deep-object-diff "^1.1.0" + emotion-theming "^10.0.19" + global "^4.3.2" + memoizerific "^1.11.3" + polished "^3.4.4" + resolve-from "^5.0.0" + ts-dedent "^2.0.0" + "@storybook/ui@6.1.15": version "6.1.15" resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.1.15.tgz#a0f6c49fcf81cf172cd2de4c8dba2be1296891f6" @@ -6133,30 +5471,11 @@ resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz#00bf9a7a73f1cad3948cdab1f8dfb774750f8c80" integrity sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q== -"@svgr/babel-plugin-transform-svg-component@^5.4.0": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.4.0.tgz#a2212b4d018e6075a058bb7e220a66959ef7a03c" - integrity sha512-zLl4Fl3NvKxxjWNkqEcpdSOpQ3LGVH2BNFQ6vjaK6sFo2IrSznrhURIPI0HAphKiiIwNYjAfE0TNoQDSZv0U9A== - "@svgr/babel-plugin-transform-svg-component@^5.5.0": version "5.5.0" resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz#583a5e2a193e214da2f3afeb0b9e8d3250126b4a" integrity sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ== -"@svgr/babel-preset@^5.4.0": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.4.0.tgz#da21854643e1c4ad2279239baa7d5a8b128c1f15" - integrity sha512-Gyx7cCxua04DBtyILTYdQxeO/pwfTBev6+eXTbVbxe4HTGhOUW6yo7PSbG2p6eJMl44j6XSequ0ZDP7bl0nu9A== - dependencies: - "@svgr/babel-plugin-add-jsx-attribute" "^5.4.0" - "@svgr/babel-plugin-remove-jsx-attribute" "^5.4.0" - "@svgr/babel-plugin-remove-jsx-empty-expression" "^5.0.1" - "@svgr/babel-plugin-replace-jsx-attribute-value" "^5.0.1" - "@svgr/babel-plugin-svg-dynamic-title" "^5.4.0" - "@svgr/babel-plugin-svg-em-dimensions" "^5.4.0" - "@svgr/babel-plugin-transform-react-native-svg" "^5.4.0" - "@svgr/babel-plugin-transform-svg-component" "^5.4.0" - "@svgr/babel-preset@^5.5.0": version "5.5.0" resolved "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz#8af54f3e0a8add7b1e2b0fcd5a882c55393df327" @@ -6180,13 +5499,6 @@ camelcase "^6.2.0" cosmiconfig "^7.0.0" -"@svgr/hast-util-to-babel-ast@^5.4.0": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.4.0.tgz#bb5d002e428f510aa5b53ec0a02377a95b367715" - integrity sha512-+U0TZZpPsP2V1WvVhqAOSTk+N+CjYHdZx+x9UBa1eeeZDXwH8pt0CrQf2+SvRl/h2CAPRFkm+Ey96+jKP8Bsgg== - dependencies: - "@babel/types" "^7.9.5" - "@svgr/hast-util-to-babel-ast@^5.5.0": version "5.5.0" resolved "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz#5ee52a9c2533f73e63f8f22b779f93cd432a5461" @@ -6194,17 +5506,7 @@ dependencies: "@babel/types" "^7.12.6" -"@svgr/plugin-jsx@5.4.x": - version "5.4.0" - resolved "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.4.0.tgz#ab47504c55615833c6db70fca2d7e489f509787c" - integrity sha512-SGzO4JZQ2HvGRKDzRga9YFSqOqaNrgLlQVaGvpZ2Iht2gwRp/tq+18Pvv9kS9ZqOMYgyix2LLxZMY1LOe9NPqw== - dependencies: - "@babel/core" "^7.7.5" - "@svgr/babel-preset" "^5.4.0" - "@svgr/hast-util-to-babel-ast" "^5.4.0" - svg-parser "^2.0.2" - -"@svgr/plugin-jsx@^5.4.0", "@svgr/plugin-jsx@^5.5.0": +"@svgr/plugin-jsx@5.5.x", "@svgr/plugin-jsx@^5.4.0", "@svgr/plugin-jsx@^5.5.0": version "5.5.0" resolved "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz#1aa8cd798a1db7173ac043466d7b52236b369000" integrity sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA== @@ -6600,11 +5902,9 @@ integrity sha512-Xwy8o12ak+iYgFr/KCVaVK5Sy+jFMiiPAID3+ObvMlBzy26XQJw5xu+a6rlHsrJENXj/AwJOGsJpVohUjAzSKQ== "@types/cors@^2.8.4", "@types/cors@^2.8.6": - version "2.8.6" - resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.6.tgz#cfaab33c49c15b1ded32f235111ce9123009bd02" - integrity sha512-invOmosX0DqbpA+cE2yoHGUlF/blyf7nB0OGYBBiH27crcVm5NmFaZkLP4Ta1hGaesckCi5lVLlydNJCxkTOSg== - dependencies: - "@types/express" "*" + version "2.8.9" + resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.9.tgz#4bd1fcac72eca8d5bec93e76c7fdcbdc1bc2cd4a" + integrity sha512-zurD1ibz21BRlAOIKP8yhrxlqKx6L9VCwkB5kMiP6nZAhoF5MvC7qS1qPA7nRcr1GJolfkQC7/EAL4hdYejLtg== "@types/cssnano@*": version "4.0.0" @@ -6868,9 +6168,9 @@ "@types/node" "*" "@types/http-proxy@*", "@types/http-proxy@^1.17.4": - version "1.17.4" - resolved "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.4.tgz#e7c92e3dbe3e13aa799440ff42e6d3a17a9d045b" - integrity sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q== + version "1.17.5" + resolved "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.5.tgz#c203c5e6e9dc6820d27a40eb1e511c70a220423d" + integrity sha512-GNkDE7bTv6Sf8JbV2GksknKOsk7OznNYHSdrtvPJXO0qJ9odZig6IZKUi5RFGi6d1bf6dgIAe4uXi3DBc7069Q== dependencies: "@types/node" "*" @@ -8873,6 +8173,21 @@ autoprefixer@^9.7.2: postcss "^7.0.32" postcss-value-parser "^4.1.0" +aws-sdk@^2.840.0: + version "2.840.0" + resolved "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.840.0.tgz#f5529c9bd3bf0be7f8e855a23ff9c12b1705418f" + integrity sha512-ngesHJqb0PXYjJNnCsAX4yLkR6JFQJB+3eDGwh3mYRjcq9voix5RfbCFQT1lwWu7bcMBPCrRIA2lJkkTMYXq+A== + dependencies: + buffer "4.9.2" + events "1.1.1" + ieee754 "1.1.13" + jmespath "0.15.0" + querystring "0.2.0" + sax "1.2.1" + url "0.10.3" + uuid "3.3.2" + xml2js "0.4.19" + aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" @@ -9522,11 +8837,6 @@ bowser@^1.7.3: resolved "https://registry.npmjs.org/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a" integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ== -bowser@^2.11.0: - version "2.11.0" - resolved "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" - integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== - boxen@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" @@ -9756,7 +9066,7 @@ buffer-xor@^1.0.3: resolved "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= -buffer@^4.3.0: +buffer@4.9.2, buffer@^4.3.0: version "4.9.2" resolved "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8" integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg== @@ -12030,6 +11340,11 @@ depd@~2.0.0: resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== +dependency-graph@^0.10.0: + version "0.10.0" + resolved "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.10.0.tgz#dfebe384f1f36faf7782be203a7a71102a6335a6" + integrity sha512-c9amUgpgxSi1bE5/sbLwcs5diLD0ygCQYmhfM5H1s5VH1mCsYkcmAL3CcNdv4kdSw6JuMoHeDGzLgj/gAXdWVg== + dependency-graph@^0.9.0: version "0.9.0" resolved "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.9.0.tgz#11aed7e203bc8b00f48356d92db27b265c445318" @@ -13199,6 +12514,11 @@ eventemitter3@^4.0.0: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== +events@1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" + integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ= + events@3.1.0, events@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/events/-/events-3.1.0.tgz#84279af1b34cb75aa88bf5ff291f6d0bd9b31a59" @@ -13496,11 +12816,6 @@ extsprintf@^1.2.0: resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= -fast-base64-decode@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz#b434a0dd7d92b12b43f26819300d2dafb83ee418" - integrity sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q== - fast-deep-equal@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" @@ -13570,11 +12885,6 @@ fast-text-encoding@^1.0.0: resolved "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== -fast-xml-parser@^3.16.0: - version "3.17.6" - resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.17.6.tgz#4f5df8cf927c3e59a10362abcfb7335c34bc5c5f" - integrity sha512-40WHI/5d2MOzf1sD2bSaTXlPn1lueJLAX6j1xH5dSAr6tNeut8B9ktEL6sjAK9yVON4uNj9//axOdBJUuruCzw== - fastest-stable-stringify@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-1.0.1.tgz#9122d406d4c9d98bea644a6b6853d5874b87b028" @@ -15476,6 +14786,11 @@ identity-obj-proxy@3.0.0: dependencies: harmony-reflect "^1.4.6" +ieee754@1.1.13: + version "1.1.13" + resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" + integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== + ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -16947,6 +16262,11 @@ jest@^26.0.1: import-local "^3.0.2" jest-cli "^26.6.3" +jmespath@0.15.0: + version "0.15.0" + resolved "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" + integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc= + jose@^1.27.1: version "1.28.0" resolved "https://registry.npmjs.org/jose/-/jose-1.28.0.tgz#0803f8c71f43cd293a9d931c555c30531f5ca5dc" @@ -18190,7 +17510,7 @@ lru-queue@0.1: dependencies: es5-ext "~0.10.2" -luxon@^1.25.0: +luxon@1.25.0, luxon@^1.25.0: version "1.25.0" resolved "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72" integrity sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ== @@ -21327,9 +20647,9 @@ prettier@^2.0.5, prettier@~2.0.5: integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg== pretty-bytes@^5.3.0: - version "5.3.0" - resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.3.0.tgz#f2849e27db79fb4d6cfe24764fc4134f165989f2" - integrity sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg== + version "5.5.0" + resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.5.0.tgz#0cecda50a74a941589498011cf23275aa82b339e" + integrity sha512-p+T744ZyjjiaFlMUZZv6YPC5JrkNj8maRmPaQCWFJFplUAzpIUTRaTcS+7wmZtUoFXHtESJb23ISliaWyz3SHA== pretty-error@^2.1.1: version "2.1.1" @@ -22069,13 +21389,6 @@ react-motion@^0.5.2: prop-types "^15.5.8" raf "^3.1.0" -react-native-get-random-values@^1.4.0: - version "1.5.0" - resolved "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.5.0.tgz#91cda18f0e66e3d9d7660ba80c61c914030c1e05" - integrity sha512-LK+Wb8dEimJkd/dub7qziDmr9Tw4chhpzVeQ6JDo4czgfG4VXbptRyOMdu8503RiMF6y9pTH6ZUTkrrpprqT7w== - dependencies: - fast-base64-decode "^1.0.0" - react-popper-tooltip@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/react-popper-tooltip/-/react-popper-tooltip-3.1.1.tgz#329569eb7b287008f04fcbddb6370452ad3f9eac" @@ -23313,6 +22626,11 @@ sanitize-html@^1.27.0: srcset "^2.0.1" xtend "^4.0.1" +sax@1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" + integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= + sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -25391,7 +24709,12 @@ tslib@^1.10.0, tslib@^1.11.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1 resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@~2.0.0, tslib@~2.0.1: +tslib@^2.0.0, tslib@^2.0.1, tslib@~2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" + integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== + +tslib@~2.0.0, tslib@~2.0.1: version "2.0.3" resolved "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== @@ -25863,6 +25186,14 @@ url-parse@^1.4.3, url-parse@^1.4.7: querystringify "^2.1.1" requires-port "^1.0.0" +url@0.10.3: + version "0.10.3" + resolved "https://registry.npmjs.org/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" + integrity sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + url@^0.11.0, url@~0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" @@ -25961,7 +25292,12 @@ utils-merge@1.0.1, utils-merge@1.x.x: resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -uuid@^3.0.0, uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2, uuid@^3.4.0: +uuid@3.3.2: + version "3.3.2" + resolved "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== + +uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2, uuid@^3.4.0: version "3.4.0" resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== @@ -26682,6 +26018,14 @@ xml-name-validator@^3.0.0: resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== +xml2js@0.4.19: + version "0.4.19" + resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" + integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q== + dependencies: + sax ">=0.6.0" + xmlbuilder "~9.0.1" + xml2js@^0.4.19, xml2js@^0.4.23: version "0.4.23" resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" @@ -26695,6 +26039,11 @@ xmlbuilder@^11.0.0, xmlbuilder@~11.0.0: resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== +xmlbuilder@~9.0.1: + version "9.0.7" + resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" + integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= + xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" From f49998d408426dd60c13a3af6382ce7784612e40 Mon Sep 17 00:00:00 2001 From: Esteban Barrios Date: Fri, 12 Feb 2021 16:20:16 +0100 Subject: [PATCH 2/4] rebase master (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix client reference * Fix CatalogClient reference in create-app too * Move catalog-client dep to create-app * Add catalog-client dep again.. * Use RequestOptions * tsc * Backwards compatible scaffolder * Fix test * Avoid importing core * Use request options * Increase code coverage * Type those variables * use the second mock * Trying to make tests work on Github too * Get clean yarn from master * Only add authorization header if token exists * Use request options * Forward authorization header token * Refactor to use context argument * Allow the registration of optional locations via the catalog-client * Make EntityRefLink a React.forwardRef in order to use it as root component in other components like ListItem * Rework the user flow of the catalog-import plugin * Update the create-app template * Add luxon and remove moment * Port moment to luxon * Add change set * Split the step label and content to be able to refactor the stepper from vertical to horizontal in the future * Fix tsc, prettier and import issues * Update .changeset/strange-olives-unite.md Co-authored-by: Himanshu Mishra * Update plugins/auth-backend/src/identity/TokenFactory.ts Co-authored-by: Himanshu Mishra * Change from minor to patch * Update DatabaseKeyStore.ts * feat: added a test for running a simple cypress server * Add luxon and remove moment * Port from moment to Luxon * Add changeset * Fixes following CR * Rebased and reinstalled yarn.lock * Changed to fromSQL * Fix prettier formatting * chore: reworking the lock file for the cypress deps * chore: adding cypress.config * chore: reworking some more examples * chore: added some more simple stuff for e2e tests with cypress * chore: reworking again * chore: making cypress nice * chore: added a sample test that works * chore: reworking tests to use he cypress github-action instead * chore: reconfigure Cypress run * chore: don't install - we have deps * chore: dump video recordings too * chore: slimming down install script a little * chore: make pretty * chore: fixing syntax * chore: fixing syntax again * Align card elements with list * Align card elements with List * Add changeset * Removed unused import * chore: archive video footage with the correct path * chore: fix lint warnings * chore: fix prettier again * chore: move files around for ease * Added pattern for component name * Add changeset * stash: push to stash * Return AboutCard title to group name * Refactor children to use shared function * TechDocs: Add visbility to migrate away from basic setup * bugfix: [3310] Favoriting a component resets interface to your owned components update changeset remove unused import fix changeset message fix vale errors? * Create utc DateTime object directly * integration: update the gitlab config mandatory fields to match reality * Resolve review comments * Remove shadowed variables * generateEntityDefinitions should return a PartialEntity * Update roadmap with Backstage Community Sessions * Make sure that SidebarItems are also active when on sub route * Add whitespace around variable * Add changeset * Add word to dictionary * Smater duration display * Add changeset * chore(deps-dev): bump @graphql-codegen/typescript from 1.18.1 to 1.20.2 Bumps [@graphql-codegen/typescript](https://github.com/dotansimha/graphql-code-generator/tree/HEAD/packages/plugins/typescript/typescript) from 1.18.1 to 1.20.2. - [Release notes](https://github.com/dotansimha/graphql-code-generator/releases) - [Changelog](https://github.com/dotansimha/graphql-code-generator/blob/master/packages/plugins/typescript/typescript/CHANGELOG.md) - [Commits](https://github.com/dotansimha/graphql-code-generator/commits/@graphql-codegen/typescript@1.20.2/packages/plugins/typescript/typescript) Signed-off-by: dependabot[bot] * chore: reworking folder structure to move cypress outside of the project * chore: updating workflow to point at the right folder now * chore: fixing cypress build * microsite: Fix color contrast for pre tags * Add package name to lockfile.ts error * Create new-mangos-tap.md * Changed changeset bump to be patch * Pass registered Logger in ServiceBuilderImpl to requestLoggingHandler `requestLoggingHandler` takes an optional `logger` parameter that it can use to log incoming requests. The `ServiceBuilderImpl` was not passing on this logger, if set, to `requestLoggingHandler` middleware * Add indices on columns referring location(id) * Add changeset * Catch catalog errors * Add changeset * Fixed parseUrl to output catalogPaths beginning with '/' * Add configurable OAuth 2.0 scopes - Add oauth2 config for optional scopes - Document oauth2 config keys - Add OAuth2 to demo app list of identity providers * incorrectly added callbackUrl * chore: added a simple readme to run some simple tests against a backstage instance * chore(deps): bump @svgr/plugin-jsx from 5.4.0 to 5.5.0 Bumps [@svgr/plugin-jsx](https://github.com/gregberge/svgr) from 5.4.0 to 5.5.0. - [Release notes](https://github.com/gregberge/svgr/releases) - [Changelog](https://github.com/gregberge/svgr/blob/main/CHANGELOG.md) - [Commits](https://github.com/gregberge/svgr/compare/v5.4.0...v5.5.0) Signed-off-by: dependabot[bot] * chore(deps): bump @types/cors from 2.8.6 to 2.8.9 Bumps [@types/cors](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/cors) from 2.8.6 to 2.8.9. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/cors) Signed-off-by: dependabot[bot] * fix: removing test e2e-test tsconfig.json file * TechDocs: Preparers will take and return an etag for cache invalidation * TechDocs: Implement etag based caching for url preparer Signed-off-by: Himanshu Mishra * TechDocs: Implement etag based caching for git preparer Signed-off-by: Himanshu Mishra * TechDocs: Warn when using legacy git preparer and dir preparer in backstage.io/techdocs-ref Context: https://github.com/backstage/backstage/issues/4409 * TechDocs: Update tests for proper url preparer caching * TechDocs: Deduplicate git clone for cache check Signed-off-by: Himanshu Mishra * TechDocs: Add changesets for deprecation and proper caching * TechDocs: Implement caching for dir preparer * Update the changesets * scaffolder-backend: remove auth-backend dependency * catalog-client: rename ApiContext to CatalogRequestOptions + avoid export * refactor existing usage of ApiContext * techdocs: don't swallow errors other than NotModifiedError * catalog-import: removed bonus code * techdocs: meaningful logs when readTree starts * Flatten the options of the CatalogImportPage * inline optional id token auth headers * Define relationship to software catalog and loose coupling by convention. * Clarify intentions around bulk vs. incremental index management. * Break apart backend plugins & clarify indexer/plugin relationship. * update changeset bump levels * [Search] documentation update (#4459) * delete link to issue as it is closed * replace usage of easy as its very subjective * Update docs/features/search/architecture.md Co-authored-by: Adam Harvey * prettier....: Co-authored-by: Adam Harvey * Own it. * scaffolder: include backstage identity token in requests * review feedback tweaks * docs/apis: update to use named plugin var * sentry: update plugin instance export name * clear other field when toggling reason * changeset * fix(pagerduty): use the luxon date library * Don't pass default as a scope to OIDC providers * docs(TechDocs): Add GitHub Actions CI example with AWS S3 * tech-radar: migrated to new composability API * cost-insights: migrate to new composability API * Changeset * Fix line endings * Prettier * Remove unnecessary scopes from OIDC defaultApi * Bump plugin-auth-backend to a minor change, add documentation for fixing it * Fix md syntax error * Fix Vale spelling error * Remove defaultScopes from OIDC api * [ImgBot] Optimize images /plugins/catalog-import/docs/catalog-import-screenshot.png -- 613.39kb -> 356.48kb (41.88%) Signed-off-by: ImgBotApp * github/workflows: use lax config checks * Use commented-out example value for scope * feat(pagerduty): add changeset * Apply suggestions from code review Co-authored-by: Rémi Doreau <32459935+ayshiff@users.noreply.github.com> * feat(catalog): add entity links card component * update entity links changeset * Fix prettier * chore(deps-dev): bump @types/http-proxy from 1.17.4 to 1.17.5 Bumps [@types/http-proxy](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/http-proxy) from 1.17.4 to 1.17.5. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/http-proxy) Signed-off-by: dependabot[bot] * Revert "Merge pull request #3953 from ayshiff/feature/techdocs-aws-sdkv3" This reverts commit 1c7771871ec46bf55f87fba5991b95938bb57017, reversing changes made to 65d5c8e6fe4364d036d69a0592c87a9037f95b8b. * Revert "Merge pull request #4035 from ayshiff/feature/catalog-backend-aws-sdk-v3" This reverts commit 1df913464889cdf63a1484b06538b76a8baed495, reversing changes made to d45a5100695fb973ae38f74f23bbced9a0101840. * Add changeset * feat: support custom app icons * backend-common: implement UrlReader.search for the other providers too * Remove changeset * docs(TechDocs): Improvements to the CI example Signed-off-by: Himanshu Mishra * fix(catalog): entity links incorrect wrapping * Remove unused functions and luxon * Removed old imports * Fix broken links in documentation (#4418) * Update IdentityApi.md * Update docs/reference/utility-apis/IdentityApi.md * Update docs/reference/utility-apis/IdentityApi.md * ran yarn docgen * Use JS Date instead of Datetime * Add line * Prettier fix * Move parseDate function * Version Packages * chore: fix the create-app version * Limit the props that are forwarded to the Link component in the EntityRefLink * Use routed tabs to link to every settings page * fix up yarn after release * chore: add lockfile to words * catalog-info: add links * Export Select component from core I saw the Select component on storybook and went to use it but it seems it's not exported. Any chance it could be exported? * bug: filepath can be returned as undefined from `git-url-parse` let's default to empty * chore: might as well do this for all parsing * chore: changeset * Add changeset for fixing requestLoggingHandler * chore(deps-dev): bump @storybook/addon-actions from 6.1.11 to 6.1.17 Bumps [@storybook/addon-actions](https://github.com/storybookjs/storybook/tree/HEAD/addons/actions) from 6.1.11 to 6.1.17. - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v6.1.17/addons/actions) Signed-off-by: dependabot[bot] * Port AboutCard * Fix tests * Revert EntityPage changes * docs: Merge auth glossary with main glossary * Remove the "Move repository" menu entry from the catalog page, as it's just a placeholder It will be easy to bring it back later, but for now it just confuses users that it's not doing anything. It's also hard to remove for integrators. * make the template cards conform to mui standard Co-authored-by: Erik Larsson Co-authored-by: Dominik Henneke Co-authored-by: Nils Streijffert Co-authored-by: Nils Streijffert Co-authored-by: Himanshu Mishra Co-authored-by: blam Co-authored-by: Nir Gazit Co-authored-by: Adam Harvey Co-authored-by: NHI TRAN Co-authored-by: Fredrik Adelöw Co-authored-by: Oliver Sand Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Debajyoti Halder Co-authored-by: Patrik Oldsberg Co-authored-by: Gowind Co-authored-by: Alan Crosswell Co-authored-by: Eric Peterson Co-authored-by: Emma Indal Co-authored-by: Ryan Vazquez Co-authored-by: Ryan Vazquez Co-authored-by: Remi Co-authored-by: Ryan Manny Co-authored-by: ImgBotApp Co-authored-by: Rémi Doreau <32459935+ayshiff@users.noreply.github.com> Co-authored-by: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Co-authored-by: Joel Low Co-authored-by: Eric Peterson Co-authored-by: github-actions[bot] Co-authored-by: Iain Billett From 3924be6479df2be82d5c7b5252c4987605f55370 Mon Sep 17 00:00:00 2001 From: Esteban Barrios Date: Fri, 12 Feb 2021 16:27:13 +0100 Subject: [PATCH 3/4] rebase master (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix client reference * Fix CatalogClient reference in create-app too * Move catalog-client dep to create-app * Add catalog-client dep again.. * Use RequestOptions * tsc * Backwards compatible scaffolder * Fix test * Avoid importing core * Use request options * Increase code coverage * Type those variables * use the second mock * Trying to make tests work on Github too * Get clean yarn from master * Only add authorization header if token exists * Use request options * Forward authorization header token * Refactor to use context argument * Allow the registration of optional locations via the catalog-client * Make EntityRefLink a React.forwardRef in order to use it as root component in other components like ListItem * Rework the user flow of the catalog-import plugin * Update the create-app template * Add luxon and remove moment * Port moment to luxon * Add change set * Split the step label and content to be able to refactor the stepper from vertical to horizontal in the future * Fix tsc, prettier and import issues * Update .changeset/strange-olives-unite.md Co-authored-by: Himanshu Mishra * Update plugins/auth-backend/src/identity/TokenFactory.ts Co-authored-by: Himanshu Mishra * Change from minor to patch * Update DatabaseKeyStore.ts * feat: added a test for running a simple cypress server * Add luxon and remove moment * Port from moment to Luxon * Add changeset * Fixes following CR * Rebased and reinstalled yarn.lock * Changed to fromSQL * Fix prettier formatting * chore: reworking the lock file for the cypress deps * chore: adding cypress.config * chore: reworking some more examples * chore: added some more simple stuff for e2e tests with cypress * chore: reworking again * chore: making cypress nice * chore: added a sample test that works * chore: reworking tests to use he cypress github-action instead * chore: reconfigure Cypress run * chore: don't install - we have deps * chore: dump video recordings too * chore: slimming down install script a little * chore: make pretty * chore: fixing syntax * chore: fixing syntax again * Align card elements with list * Align card elements with List * Add changeset * Removed unused import * chore: archive video footage with the correct path * chore: fix lint warnings * chore: fix prettier again * chore: move files around for ease * Added pattern for component name * Add changeset * stash: push to stash * Return AboutCard title to group name * Refactor children to use shared function * TechDocs: Add visbility to migrate away from basic setup * bugfix: [3310] Favoriting a component resets interface to your owned components update changeset remove unused import fix changeset message fix vale errors? * Create utc DateTime object directly * integration: update the gitlab config mandatory fields to match reality * Resolve review comments * Remove shadowed variables * generateEntityDefinitions should return a PartialEntity * Update roadmap with Backstage Community Sessions * Make sure that SidebarItems are also active when on sub route * Add whitespace around variable * Add changeset * Add word to dictionary * Smater duration display * Add changeset * chore(deps-dev): bump @graphql-codegen/typescript from 1.18.1 to 1.20.2 Bumps [@graphql-codegen/typescript](https://github.com/dotansimha/graphql-code-generator/tree/HEAD/packages/plugins/typescript/typescript) from 1.18.1 to 1.20.2. - [Release notes](https://github.com/dotansimha/graphql-code-generator/releases) - [Changelog](https://github.com/dotansimha/graphql-code-generator/blob/master/packages/plugins/typescript/typescript/CHANGELOG.md) - [Commits](https://github.com/dotansimha/graphql-code-generator/commits/@graphql-codegen/typescript@1.20.2/packages/plugins/typescript/typescript) Signed-off-by: dependabot[bot] * chore: reworking folder structure to move cypress outside of the project * chore: updating workflow to point at the right folder now * chore: fixing cypress build * microsite: Fix color contrast for pre tags * Add package name to lockfile.ts error * Create new-mangos-tap.md * Changed changeset bump to be patch * Pass registered Logger in ServiceBuilderImpl to requestLoggingHandler `requestLoggingHandler` takes an optional `logger` parameter that it can use to log incoming requests. The `ServiceBuilderImpl` was not passing on this logger, if set, to `requestLoggingHandler` middleware * Add indices on columns referring location(id) * Add changeset * Catch catalog errors * Add changeset * Fixed parseUrl to output catalogPaths beginning with '/' * Add configurable OAuth 2.0 scopes - Add oauth2 config for optional scopes - Document oauth2 config keys - Add OAuth2 to demo app list of identity providers * incorrectly added callbackUrl * chore: added a simple readme to run some simple tests against a backstage instance * chore(deps): bump @svgr/plugin-jsx from 5.4.0 to 5.5.0 Bumps [@svgr/plugin-jsx](https://github.com/gregberge/svgr) from 5.4.0 to 5.5.0. - [Release notes](https://github.com/gregberge/svgr/releases) - [Changelog](https://github.com/gregberge/svgr/blob/main/CHANGELOG.md) - [Commits](https://github.com/gregberge/svgr/compare/v5.4.0...v5.5.0) Signed-off-by: dependabot[bot] * chore(deps): bump @types/cors from 2.8.6 to 2.8.9 Bumps [@types/cors](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/cors) from 2.8.6 to 2.8.9. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/cors) Signed-off-by: dependabot[bot] * fix: removing test e2e-test tsconfig.json file * TechDocs: Preparers will take and return an etag for cache invalidation * TechDocs: Implement etag based caching for url preparer Signed-off-by: Himanshu Mishra * TechDocs: Implement etag based caching for git preparer Signed-off-by: Himanshu Mishra * TechDocs: Warn when using legacy git preparer and dir preparer in backstage.io/techdocs-ref Context: https://github.com/backstage/backstage/issues/4409 * TechDocs: Update tests for proper url preparer caching * TechDocs: Deduplicate git clone for cache check Signed-off-by: Himanshu Mishra * TechDocs: Add changesets for deprecation and proper caching * TechDocs: Implement caching for dir preparer * Update the changesets * scaffolder-backend: remove auth-backend dependency * catalog-client: rename ApiContext to CatalogRequestOptions + avoid export * refactor existing usage of ApiContext * techdocs: don't swallow errors other than NotModifiedError * catalog-import: removed bonus code * techdocs: meaningful logs when readTree starts * Flatten the options of the CatalogImportPage * inline optional id token auth headers * Define relationship to software catalog and loose coupling by convention. * Clarify intentions around bulk vs. incremental index management. * Break apart backend plugins & clarify indexer/plugin relationship. * update changeset bump levels * [Search] documentation update (#4459) * delete link to issue as it is closed * replace usage of easy as its very subjective * Update docs/features/search/architecture.md Co-authored-by: Adam Harvey * prettier....: Co-authored-by: Adam Harvey * Own it. * scaffolder: include backstage identity token in requests * review feedback tweaks * docs/apis: update to use named plugin var * sentry: update plugin instance export name * clear other field when toggling reason * changeset * fix(pagerduty): use the luxon date library * Don't pass default as a scope to OIDC providers * docs(TechDocs): Add GitHub Actions CI example with AWS S3 * tech-radar: migrated to new composability API * cost-insights: migrate to new composability API * Changeset * Fix line endings * Prettier * Remove unnecessary scopes from OIDC defaultApi * Bump plugin-auth-backend to a minor change, add documentation for fixing it * Fix md syntax error * Fix Vale spelling error * Remove defaultScopes from OIDC api * [ImgBot] Optimize images /plugins/catalog-import/docs/catalog-import-screenshot.png -- 613.39kb -> 356.48kb (41.88%) Signed-off-by: ImgBotApp * github/workflows: use lax config checks * Use commented-out example value for scope * feat(pagerduty): add changeset * Apply suggestions from code review Co-authored-by: Rémi Doreau <32459935+ayshiff@users.noreply.github.com> * feat(catalog): add entity links card component * update entity links changeset * Fix prettier * chore(deps-dev): bump @types/http-proxy from 1.17.4 to 1.17.5 Bumps [@types/http-proxy](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/http-proxy) from 1.17.4 to 1.17.5. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/http-proxy) Signed-off-by: dependabot[bot] * Revert "Merge pull request #3953 from ayshiff/feature/techdocs-aws-sdkv3" This reverts commit 1c7771871ec46bf55f87fba5991b95938bb57017, reversing changes made to 65d5c8e6fe4364d036d69a0592c87a9037f95b8b. * Revert "Merge pull request #4035 from ayshiff/feature/catalog-backend-aws-sdk-v3" This reverts commit 1df913464889cdf63a1484b06538b76a8baed495, reversing changes made to d45a5100695fb973ae38f74f23bbced9a0101840. * Add changeset * feat: support custom app icons * backend-common: implement UrlReader.search for the other providers too * Remove changeset * docs(TechDocs): Improvements to the CI example Signed-off-by: Himanshu Mishra * fix(catalog): entity links incorrect wrapping * Remove unused functions and luxon * Removed old imports * Fix broken links in documentation (#4418) * Update IdentityApi.md * Update docs/reference/utility-apis/IdentityApi.md * Update docs/reference/utility-apis/IdentityApi.md * ran yarn docgen * Use JS Date instead of Datetime * Add line * Prettier fix * Move parseDate function * Version Packages * chore: fix the create-app version * Limit the props that are forwarded to the Link component in the EntityRefLink * Use routed tabs to link to every settings page * fix up yarn after release * chore: add lockfile to words * catalog-info: add links * Export Select component from core I saw the Select component on storybook and went to use it but it seems it's not exported. Any chance it could be exported? * bug: filepath can be returned as undefined from `git-url-parse` let's default to empty * chore: might as well do this for all parsing * chore: changeset * Add changeset for fixing requestLoggingHandler * chore(deps-dev): bump @storybook/addon-actions from 6.1.11 to 6.1.17 Bumps [@storybook/addon-actions](https://github.com/storybookjs/storybook/tree/HEAD/addons/actions) from 6.1.11 to 6.1.17. - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v6.1.17/addons/actions) Signed-off-by: dependabot[bot] * Port AboutCard * Fix tests * Revert EntityPage changes * docs: Merge auth glossary with main glossary * Remove the "Move repository" menu entry from the catalog page, as it's just a placeholder It will be easy to bring it back later, but for now it just confuses users that it's not doing anything. It's also hard to remove for integrators. * make the template cards conform to mui standard Co-authored-by: Erik Larsson Co-authored-by: Dominik Henneke Co-authored-by: Nils Streijffert Co-authored-by: Nils Streijffert Co-authored-by: Himanshu Mishra Co-authored-by: blam Co-authored-by: Nir Gazit Co-authored-by: Adam Harvey Co-authored-by: NHI TRAN Co-authored-by: Fredrik Adelöw Co-authored-by: Oliver Sand Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Debajyoti Halder Co-authored-by: Patrik Oldsberg Co-authored-by: Gowind Co-authored-by: Alan Crosswell Co-authored-by: Eric Peterson Co-authored-by: Emma Indal Co-authored-by: Ryan Vazquez Co-authored-by: Ryan Vazquez Co-authored-by: Remi Co-authored-by: Ryan Manny Co-authored-by: ImgBotApp Co-authored-by: Rémi Doreau <32459935+ayshiff@users.noreply.github.com> Co-authored-by: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Co-authored-by: Joel Low Co-authored-by: Eric Peterson Co-authored-by: github-actions[bot] Co-authored-by: Iain Billett From bdf5c24328a67e06af62cc9ddfb4c72cbbc890f1 Mon Sep 17 00:00:00 2001 From: ebarrios Date: Mon, 15 Feb 2021 14:50:44 +0100 Subject: [PATCH 4/4] The apps entry in the yaml file need to be at the same level as the host, if it is not the app will not start and complain about it --- docs/plugins/github-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index d3b0e36cd9..1cac85fbce 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -77,6 +77,6 @@ method of distributing secrets. integrations: github: - host: github.com - apps: + apps: - $include: example-backstage-app-credentials.yaml ```