Merge branch 'backstage:master' into master

This commit is contained in:
Bogdan Nechyporenko
2022-09-12 10:36:00 +02:00
committed by GitHub
98 changed files with 1321 additions and 304 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-node': minor
---
Added refresh function to the `EntityProviderConnection` to be able to schedule refreshes from entity providers.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-bazaar': patch
---
Fixed broken routing by removing the wrapping `Router` from the `RoutedTabs` children.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Support showing counts in option labels of the `EntityTagPicker`. You can enable this by adding the `showCounts` property
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': minor
---
Adding `validateEntity` method that calls `/validate-entity` endpoint.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/dev-utils': patch
---
Fixed routing when using React Router v6 stable.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/backend-app-api': patch
'@backstage/backend-defaults': patch
'@backstage/backend-test-utils': patch
---
Updated to support new `ServiceFactory` formats.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Addition of a dismissible Error Banner in Scaffolder page
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': minor
---
Added the `refresh` function to the Connection of the entity providers.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
The `createServiceFactory` method has been updated to return a higher-order factory that can accept options.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-auth-backend': minor
---
The auth0 integration is updated to use the `passport-auth0` library. The configuration under `auth.providers.auth0.\*` now supports an optional `audience` parameter; providing that allows you to connect to the correct API to get permissions, access tokens, and full profile information.
[What is an Audience](https://community.auth0.com/t/what-is-the-audience/71414)
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/integration': patch
---
Improved caching around github app tokens.
Tokens are now cached for 50 minutes, not 10.
Calls to get app installations are also included in this cache.
If you have more than one github app configured, consider adding `allowedInstallationOwners` to your apps configuration to gain the most benefit from these performance changes.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/app-defaults': patch
'@backstage/test-utils': patch
---
Add missing peer dependencies
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Updated `versions:bump` command to be compatible with Yarn 3.
+4
View File
@@ -211,3 +211,7 @@ _You can do this by using the [Adopter form](https://form.typeform.com/to/zcOaKi
| [Stone](https://stone.com.br/) | [Levy Fialho](mailto:lfialho@stone.com.br) | We're using Backstage as our Credit Team Developer Portal and microservices catalog for mapping ownership. We are also using mkdocs for microservices documentation. |
| [REI](https://www.rei.com/) | [Jen Evans](mailto:jenevan@rei.com) | Developer portal focused on an enterprise-wide app catalog to track ownership and surface APIs. |
| [next](https://next.me) | [Devan Jeronimo Nack](mailto:devan.j.nack@next.me), [Everson Crusara](mailto:everson.crusara@next.me), [Thiago Carneiro da Silva](mailto:thiagoc.silva@next.me) | We are building our Internal Developer Portal using Backstage to improve developer's experience by centralizing our services catalog and identifing microservices' ownership. Also we are going to improve Technical Documentation and speed up development using software templates to help squads in creation and deployment of new microservices. |
| [Vipps](https://vipps.no) | [Martin Ehrnst](https://github.com/ehrnst) | Vipps use backstage for our service catalog, documentation, and developer portal. Using templates we are able to simplify the developer experience when deploying new services to our platform. |
| [Ferrovial](https://ferrovial.com) | [Jose Luis Rosado](mailto:jlrosado@ferrovial.com) | Backstage is helping us to improve and acelerate dev experience helping teams to quickly find technical documentation, infrastructure templates, pipelines, software components and quickstarters that have been developed by our squads in a inner source friendly environment.
|
+1
View File
@@ -34,6 +34,7 @@ auth:
clientId: ${AUTH_AUTH0_CLIENT_ID}
clientSecret: ${AUTH_AUTH0_CLIENT_SECRET}
domain: ${AUTH_AUTH0_DOMAIN_ID}
audience: ${AUTH_AUTH0_AUDIENCE}
```
The Auth0 provider is a structure with three configuration keys:
+3 -1
View File
@@ -98,7 +98,9 @@ integrations:
### Limiting the GitHub App installations
If you want to limit the GitHub app installations visible to backstage you may
optionally include the `allowedInstallationOwners` option.
optionally include the `allowedInstallationOwners` option. If you configure
multiple apps, specifying this will bring some small performance benefits
as backstage can more easily select which app to use for a URL.
```yaml
appId: app id
@@ -0,0 +1,92 @@
---
# prettier-ignore
title: FYI 📣 The Plugin Analytics API
author: Eric Peterson, Spotify
authorURL: https://github.com/iamEAP
authorImageURL: https://avatars.githubusercontent.com/u/3496491?v=4
---
**TL;DR** If you didn't know, now you know: the Backstage plugin analytics API is here to help you understand how developers in your organization are using Backstage.
![The Plugin Analytics API](assets/22-09-08/analytics-api-fyi.png)
<!--truncate-->
## What is the plugin analytics API?
The plugin analytics API is a [utility api](https://backstage.io/docs/api/utility-apis) available by default in every Backstage instance, intended to bridge the gap between the needs of Backstage integrators and plugin developers. While Backstage integrators want visibility into the plugins theyve installed, they lack the power to instrument those plugins. And although plugin developers have the power to instrument plugins, they cant do so without a single, vendor-agnostic way to track events. Enter: the plugin analytics API.
While “analytics” as a concept can be broad, the goal of the API is narrowly focused: empower those deploying Backstage to understand usage of their instance. The plugin analytics API isnt designed to solve for observability use-cases like tracing, logging, performance monitoring, error metrics, or alerting. Rather, the API is designed to capture and quantify real user interactions, which can form the basis for metrics like daily active users, top plugins, and more.
## Start collecting data
Backstage core (and a few other plugins) are already instrumented with [key events](https://backstage.io/docs/plugins/analytics#key-events) that are ready for you to start collecting and analyzing.
The simplest way to get started is to use one of the [supported analytics tools](https://backstage.io/docs/plugins/analytics#supported-analytics-tools) and install its provided API implementation like you would any other utility API. For example:
```tsx
// packages/app/src/apis.ts
import {
analyticsApiRef,
configApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { GoogleAnalytics } from '@backstage/plugin-analytics-module-ga';
export const apis: AnyApiFactory[] = [
// Instantiate and register the GA Analytics API Implementation.
createApiFactory({
api: analyticsApiRef,
deps: { configApi: configApiRef, identityApi: identityApiRef },
factory: ({ configApi, identityApi }) =>
GoogleAnalytics.fromConfig(configApi, {
identityApi,
}),
}),
];
```
If your chosen analytics tool doesnt have an integration yet, you can write a custom integration by [following these instructions](https://backstage.io/docs/plugins/analytics#writing-integrations). (And if youre integrating with a publicly available analytics service, as opposed to a custom in-house system, why not [consider contributing it back to the community](https://backstage.io/docs/plugins/create-a-plugin)?)
## Instrument plugins
While some key events are already instrumented, there may be important actions in open source plugins that are un-instrumented, not to mention in your custom, InnerSource plugins. Luckily, the plugin analytics API can be leveraged by open source and InnerSource plugins all the same.
To capture an event, invoke the `useAnalytics()` react hook and call the function it returns when the user performs the action you wish to track (e.g. merging a pull request):
```tsx
import { useAnalytics } from '@backstage/core-plugin-api';
const analytics = useAnalytics();
analytics.captureEvent('merge', pullRequestName);
```
Dont worry about having to stuff additional levels of detail into just the event action and subject, you can provide extra dimensional data on the `attributes` property, as well as a primary metric on the `value` property, like this:
```tsx
analytics.captureEvent('merge', pullRequestName, {
value: pullRequestAgeInMinutes,
attributes: {
org: orgName,
repo: repoName,
},
});
```
In situations where your plugin is tracking multiple events and you want all of those events to share common dimensional data, you can use the `<AnalyticsContext>`. Every event captured in child components underneath this context automatically inherits the values you set:
```tsx
import { AnalyticsContext } from '@backstage/core-plugin-api';
<AnalyticsContext attributes={{ vcsProvider: 'github' }}>
{children}
</AnalyticsContext
```
In fact, Backstage core uses an `<AnalyticsContext>` to automatically decorate every event with a corresponding plugin ID and an extension name in order to facilitate plugin-level analysis.
While the above should be enough to get you going, dont forget to check out [the complete guide to event capture](https://backstage.io/docs/plugins/analytics#capturing-events), which covers event naming considerations, testing, and more.
## Get involved
If you didnt know, now you know! If youre passionate about data and want to help push the Backstage analytics ecosystem forward, join us in the [#analytics channel on discord](https://discord.com/channels/687207715902193673/1007303347914690610), contribute [integration ideas](https://github.com/backstage/backstage/issues/new?assignees=&labels=plugin&template=plugin_template.md&title=%5BAnalytics+Module%5D+THE+ANALYTICS+TOOL+TO+INTEGRATE), or [suggest a new analytics event](https://github.com/backstage/backstage/issues/new?assignees=&labels=enhancement&template=feature_template.md&title=[Analytics%20Event]:%20THE+EVENT+TO+CAPTURE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+11 -2
View File
@@ -53,14 +53,23 @@ Block.QuoteContainer = simpleComponent('div', 'Block__QuoteContainer');
Block.Quote = simpleComponent('p', 'Block__Quote');
Block.Divider = simpleComponent('p', 'Block__Divider', ['quote']);
Block.MediaFrame = simpleComponent('div', 'Block__MediaFrame');
Block.Graphics = ({ padding, children }) => {
Block.Graphics = ({ padding, children, innerPadding, width }) => {
const style = {};
const innerStyle = {};
if (padding) {
style.padding = `${padding}% 0`;
}
if (width) {
style.width = width;
}
if (innerPadding) {
innerStyle.padding = `${innerPadding}% 0`;
}
return (
<div className="Block__GraphicsContainer" style={style}>
<div className="Block__Graphics" children={children} />
<div className="Block__Graphics" children={children} style={innerStyle} />
</div>
);
};
@@ -0,0 +1,9 @@
---
title: LDAP Auth
author: ImmobiliareLabs
authorUrl: https://github.com/immobiliare
category: Authentication
description: Authenticate users to an external LDAP server
documentation: https://github.com/immobiliare/backstage-plugin-ldap-auth/blob/main/README.md
iconUrl: https://avatars.githubusercontent.com/u/10090828
npmPackageName: '@immobiliarelabs/backstage-plugin-ldap-auth'
@@ -0,0 +1,10 @@
---
title: Backstage Search
author: Spotify
authorUrl: https://github.com/spotify
category: Core Feature
description: A composable and extensible search platform built to fit your organizations needs and find information quickly.
documentation: https://backstage.io/docs/features/software-catalog/software-catalog-overview
iconUrl: img/backstage-search-platform.svg
npmPackageName: '@backstage/plugin-search'
order: 5
+101 -4
View File
@@ -88,7 +88,7 @@ class Index extends React.Component {
/>
<Block.SmallTitle small>The Speed Paradox</Block.SmallTitle>
<Block.Paragraph>
At Spotify, weve always believed in the speed and ingenuity
At Spotify, we've always believed in the speed and ingenuity
that comes from having autonomous development teams. But as we
learned firsthand, the faster you grow, the more fragmented
and complex your software ecosystem becomes. And then
@@ -238,7 +238,7 @@ class Index extends React.Component {
</Block.SmallTitle>
<Block.Paragraph>
Using templates, engineers can spin up a new microservice with
your organizations best practices built-in, right from the
your organization's best practices built-in, right from the
start
</Block.Paragraph>
</Block.TextBox>
@@ -350,7 +350,7 @@ class Index extends React.Component {
<BulletLine />
<Block.SmallTitle small>Easy to maintain</Block.SmallTitle>
<Block.Paragraph>
Updating code? Update your documentation while youre there
Updating code? Update your documentation while you're there
with docs and code in the same place, it becomes a natural
part of your workstream
</Block.Paragraph>
@@ -394,6 +394,103 @@ class Index extends React.Component {
</ActionBlock.Link>
</ActionBlock>
<Block className="stripe-top bg-teal-top-right" wrapped>
<Block.Container wrapped>
<Block.TextBox wide>
{' '}
<img
className="Block__GIF"
src={`${baseUrl}animations/backstage-search-platform-icon-1.gif`}
/>
<Block.Subtitle>Backstage Search Platform</Block.Subtitle>
<Block.Title half>
A search platform made just for you
</Block.Title>
</Block.TextBox>
<Breakpoint
narrow={
<Block.Graphics innerPadding={20}>
<Block.Graphic
y={-8}
padding={0}
width={100}
src={`${baseUrl}img/search-platform-overview-small.png`}
/>
</Block.Graphics>
}
/>
<Block.TextBox small>
<BulletLine />
<Block.SmallTitle small>
Way more than a text box{' '}
</Block.SmallTitle>
<Block.Paragraph>
Backstage Search more than just a box you type questions into
it's an entire platform all by itself, which you can
customize to fit your organization's needs
</Block.Paragraph>
</Block.TextBox>
<Block.TextBox small>
<BulletLine />
<Block.SmallTitle small>
Search the way you want
</Block.SmallTitle>
<Block.Paragraph>
Bring your own search engine, create a customized search page
experience, or edit the look and feel of each search result
</Block.Paragraph>
</Block.TextBox>
<Block.TextBox small>
<BulletLine />
<Block.SmallTitle small>
Index everything, find anything
</Block.SmallTitle>
<Block.Paragraph>
With an extensible backend, you can search beyond the Software
Catalog and index any source you'd like — whether it's
TechDocs or Confluence and Stack Overflow
</Block.Paragraph>
</Block.TextBox>
<Block.TextBox small>
<BulletLine />
<Block.SmallTitle small>
Discoverability unlocked
</Block.SmallTitle>
<Block.Paragraph>
New hires and seasoned employees alike can easily search your
infrastructure instead of getting lost in it
</Block.Paragraph>
</Block.TextBox>
</Block.Container>
<Breakpoint
wide={
<Block.Graphics innerPadding={25} width={'1100px'}>
<Block.Graphic
padding={0}
src={`${baseUrl}img/search-platform-overview.png`}
width={100}
style={{
borderRadius: '8px',
boxShadow: '3px 8px 8px rgba(0,0,0,0.3)',
bottom: '8px',
}}
/>
</Block.Graphics>
}
/>
</Block>
<ActionBlock className="stripe bg-teal">
<ActionBlock.Title>
Learn more about Backstage Search
</ActionBlock.Title>
<ActionBlock.Link
href={`https://backstage.io/docs/features/search/search-overview`}
>
Read
</ActionBlock.Link>
</ActionBlock>
<Block className="stripe-top bg-teal-bottom" wrapped>
<Block.Container wrapped>
<Block.TextBox wide>
@@ -512,7 +609,7 @@ class Index extends React.Component {
<BulletLine />
<Block.SmallTitle small>BYO Plugins</Block.SmallTitle>
<Block.Paragraph>
If you dont see the plugin you need, its simple to build
If you don't see the plugin you need, it's simple to build
your own
</Block.Paragraph>
</Block.TextBox>
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+5 -1
View File
@@ -1086,11 +1086,15 @@ code {
display: flex;
}
.Banner--hidden {
.Banner--hiding {
opacity: 0;
transition: opacity 200ms ease-in-out;
}
.Banner--hidden {
display: none;
}
.Banner a {
color: #000;
text-decoration: underline;
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 380 380" style="enable-background:new 0 0 380 380;" xml:space="preserve">
<style type="text/css">
.st0{fill:#121212;stroke:#7DF3E1;stroke-width:3;stroke-linejoin:round;stroke-miterlimit:10;}
.st1{fill:#121212;stroke:#7DF3E1;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style>
<g>
<path class="st0" d="M148.71,175.36l111.65,103.46c7.55,6.99,5.63,19.4-3.67,23.79h0c-5.36,2.53-11.72,1.55-16.07-2.48
L120.4,188.73"/>
<circle class="st0" cx="129.29" cy="165.67" r="90.21"/>
<circle class="st0" cx="129.29" cy="165.67" r="66.34"/>
<path class="st1" d="M82.1,170.2c-0.47-4.87-0.17-9.73,0.84-14.42"/>
<path class="st1" d="M89.39,140.09c4.93-7.7,12.13-14.06,21.16-17.94"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1015 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

+2 -1
View File
@@ -10,8 +10,9 @@ window.addEventListener('DOMContentLoaded', () => {
const dismissButton = banner.querySelector('[data-banner-dismiss]');
if (dismissButton) {
dismissButton.addEventListener('click', () => {
banner.classList.add('Banner--hidden');
banner.classList.add('Banner--hiding');
localStorage.setItem(storageKey, 'true');
setTimeout(() => banner.classList.add('Banner--hidden'), 300);
});
}
});
+1
View File
@@ -67,6 +67,7 @@
"concurrently": "^7.0.0",
"cross-env": "^7.0.0",
"e2e-test": "workspace:*",
"eslint": "^8.6.0",
"eslint-plugin-notice": "^0.9.10",
"fs-extra": "10.1.0",
"husky": "^8.0.0",
+2
View File
@@ -42,6 +42,8 @@
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0",
"react-dom": "^16.13.1 || ^17.0.0",
"react-router": "6.0.0-beta.0 || ^6.3.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
+5 -4
View File
@@ -66,10 +66,10 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@octokit/rest": "^19.0.3",
"@roadiehq/backstage-plugin-buildkite": "^2.0.0",
"@roadiehq/backstage-plugin-github-insights": "^2.0.0",
"@roadiehq/backstage-plugin-github-pull-requests": "^2.0.0",
"@roadiehq/backstage-plugin-travis-ci": "^2.0.0",
"@roadiehq/backstage-plugin-buildkite": "^2.0.8",
"@roadiehq/backstage-plugin-github-insights": "^2.0.5",
"@roadiehq/backstage-plugin-github-pull-requests": "^2.2.7",
"@roadiehq/backstage-plugin-travis-ci": "^2.0.5",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^17.0.2",
@@ -88,6 +88,7 @@
"@testing-library/user-event": "^14.0.0",
"@types/jquery": "^3.3.34",
"@types/node": "^16.11.26",
"@types/react": "*",
"@types/react-dom": "*",
"@types/zen-observable": "^0.8.0",
"cross-env": "^7.0.0",
+27 -13
View File
@@ -28,10 +28,12 @@ export interface Backend {
}
// @public (undocumented)
export const cacheFactory: ServiceFactory<PluginCacheManager>;
export const cacheFactory: (
options?: undefined,
) => ServiceFactory<PluginCacheManager>;
// @public (undocumented)
export const configFactory: ServiceFactory<Config>;
export const configFactory: (options?: undefined) => ServiceFactory<Config>;
// @public (undocumented)
export function createSpecializedBackend(
@@ -41,28 +43,36 @@ export function createSpecializedBackend(
// @public (undocumented)
export interface CreateSpecializedBackendOptions {
// (undocumented)
services: ServiceFactory[];
services: (ServiceFactory | (() => ServiceFactory))[];
}
// @public (undocumented)
export const databaseFactory: ServiceFactory<PluginDatabaseManager>;
export const databaseFactory: (
options?: undefined,
) => ServiceFactory<PluginDatabaseManager>;
// @public (undocumented)
export const discoveryFactory: ServiceFactory<PluginEndpointDiscovery>;
export const discoveryFactory: (
options?: undefined,
) => ServiceFactory<PluginEndpointDiscovery>;
// @public (undocumented)
export const httpRouterFactory: ServiceFactory<HttpRouterService>;
export const httpRouterFactory: (
options?: undefined,
) => ServiceFactory<HttpRouterService>;
// @public (undocumented)
export const loggerFactory: ServiceFactory<Logger>;
export const loggerFactory: (options?: undefined) => ServiceFactory<Logger>;
// @public (undocumented)
export const permissionsFactory: ServiceFactory<
PermissionAuthorizer | PermissionEvaluator
>;
export const permissionsFactory: (
options?: undefined,
) => ServiceFactory<PermissionAuthorizer | PermissionEvaluator>;
// @public (undocumented)
export const schedulerFactory: ServiceFactory<PluginTaskScheduler>;
export const schedulerFactory: (
options?: undefined,
) => ServiceFactory<PluginTaskScheduler>;
// @public (undocumented)
export type ServiceOrExtensionPoint<T = unknown> =
@@ -70,8 +80,12 @@ export type ServiceOrExtensionPoint<T = unknown> =
| ServiceRef<T>;
// @public (undocumented)
export const tokenManagerFactory: ServiceFactory<TokenManager>;
export const tokenManagerFactory: (
options?: undefined,
) => ServiceFactory<TokenManager>;
// @public (undocumented)
export const urlReaderFactory: ServiceFactory<UrlReader>;
export const urlReaderFactory: (
options?: undefined,
) => ServiceFactory<UrlReader>;
```
@@ -26,7 +26,9 @@ import { stringifyError } from '@backstage/errors';
* @internal
*/
export type InternalServiceRef<T> = ServiceRef<T> & {
__defaultFactory?: (service: ServiceRef<T>) => Promise<ServiceFactory<T>>;
__defaultFactory?: (
service: ServiceRef<T>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
};
export class ServiceRegistry {
@@ -40,8 +42,18 @@ export class ServiceRegistry {
}
>;
constructor(factories: ServiceFactory<any>[]) {
this.#providedFactories = new Map(factories.map(f => [f.service.id, f]));
constructor(
factories: Array<ServiceFactory<unknown> | (() => ServiceFactory<unknown>)>,
) {
this.#providedFactories = new Map(
factories.map(f => {
if (typeof f === 'function') {
const cf = f();
return [cf.service.id, cf];
}
return [f.service.id, f];
}),
);
this.#loadedDefaultFactories = new Map();
this.#implementations = new Map();
}
@@ -57,9 +69,11 @@ export class ServiceRegistry {
if (!factory) {
let loadedFactory = this.#loadedDefaultFactories.get(defaultFactory!);
if (!loadedFactory) {
loadedFactory = Promise.resolve().then(
() => defaultFactory!(ref) as Promise<ServiceFactory>,
);
loadedFactory = Promise.resolve()
.then(() => defaultFactory!(ref))
.then(f =>
typeof f === 'function' ? f() : f,
) as Promise<ServiceFactory>;
this.#loadedDefaultFactories.set(defaultFactory!, loadedFactory);
}
// NOTE: This await is safe as long as #providedFactories is not mutated.
+4 -2
View File
@@ -43,7 +43,7 @@ export interface BackendRegisterInit {
* @public
*/
export interface CreateSpecializedBackendOptions {
services: ServiceFactory[];
services: (ServiceFactory | (() => ServiceFactory))[];
}
export type ServiceHolder = {
@@ -56,7 +56,9 @@ export type ServiceHolder = {
export function createSpecializedBackend(
options: CreateSpecializedBackendOptions,
): Backend {
return new BackstageBackend(options.services);
return new BackstageBackend(
options.services.map(s => (typeof s === 'function' ? s() : s)),
);
}
/**
+1 -1
View File
@@ -12,6 +12,6 @@ export function createBackend(options?: CreateBackendOptions): Backend;
// @public (undocumented)
export interface CreateBackendOptions {
// (undocumented)
services?: ServiceFactory[];
services?: (ServiceFactory | (() => ServiceFactory))[];
}
```
+2 -12
View File
@@ -47,24 +47,14 @@ export const defaultServiceFactories = [
* @public
*/
export interface CreateBackendOptions {
services?: ServiceFactory[];
services?: (ServiceFactory | (() => ServiceFactory))[];
}
/**
* @public
*/
export function createBackend(options?: CreateBackendOptions): Backend {
const services = new Map<string, ServiceFactory>(
defaultServiceFactories.map(sf => [sf.service.id, sf as ServiceFactory]),
);
if (options?.services) {
for (const sf of options.services) {
services.set(sf.service.id, sf);
}
}
return createSpecializedBackend({
services: Array.from(services.values()),
services: [...defaultServiceFactories, ...(options?.services ?? [])],
});
}
+15 -3
View File
@@ -109,16 +109,28 @@ export function createServiceFactory<
TDeps extends {
[name in string]: unknown;
},
TOpts extends
| {
[name in string]: unknown;
}
| undefined = undefined,
>(factory: {
service: ServiceRef<TService>;
deps: TypesToServiceRef<TDeps>;
factory(deps: DepsToDepFactories<TDeps>): Promise<FactoryFunc<TImpl>>;
}): ServiceFactory<TService>;
factory(
deps: DepsToDepFactories<TDeps>,
options: TOpts,
): Promise<FactoryFunc<TImpl>>;
}): undefined extends TOpts
? (options?: TOpts) => ServiceFactory<TService>
: (options: TOpts) => ServiceFactory<TService>;
// @public (undocumented)
export function createServiceRef<T>(options: {
id: string;
defaultFactory?: (service: ServiceRef<T>) => Promise<ServiceFactory<T>>;
defaultFactory?: (
service: ServiceRef<T>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
}): ServiceRef<T>;
// @public (undocumented)
@@ -0,0 +1,89 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createServiceFactory, createServiceRef } from './types';
describe('createServiceFactory', () => {
it('should create a meta factory with no options', () => {
const ref = createServiceRef<string>({ id: 'x' });
const metaFactory = createServiceFactory({
service: ref,
deps: {},
async factory(_deps) {
return async () => 'x';
},
});
expect(metaFactory).toEqual(expect.any(Function));
expect(metaFactory().service).toBe(ref);
// @ts-expect-error
metaFactory('string');
// @ts-expect-error
metaFactory({});
// @ts-expect-error
metaFactory({ x: 1 });
// @ts-expect-error
metaFactory(null);
metaFactory(undefined);
metaFactory();
});
it('should create a meta factory with optional options', () => {
const ref = createServiceRef<string>({ id: 'x' });
const metaFactory = createServiceFactory({
service: ref,
deps: {},
async factory(_deps, _opts?: { x: number }) {
return async () => 'x';
},
});
expect(metaFactory).toEqual(expect.any(Function));
// @ts-expect-error
metaFactory('string');
// @ts-expect-error
metaFactory({});
metaFactory({ x: 1 });
// @ts-expect-error
metaFactory(null);
metaFactory(undefined);
metaFactory();
});
it('should create a meta factory with required options', () => {
const ref = createServiceRef<string>({ id: 'x' });
const metaFactory = createServiceFactory({
service: ref,
deps: {},
async factory(_deps, _opts: { x: number }) {
return async () => 'x';
},
});
expect(metaFactory).toEqual(expect.any(Function));
// @ts-expect-error
metaFactory('string');
// @ts-expect-error
metaFactory({});
metaFactory({ x: 1 });
// @ts-expect-error
metaFactory(null);
// @ts-expect-error
metaFactory(undefined);
// @ts-expect-error
metaFactory();
});
});
@@ -41,7 +41,9 @@ export type InternalServiceRef<T> = ServiceRef<T> & {
* The default factory that will be used to create service
* instances if no other factory is provided.
*/
__defaultFactory?: (service: ServiceRef<T>) => Promise<ServiceFactory<T>>;
__defaultFactory?: (
service: ServiceRef<T>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
};
/** @public */
@@ -67,7 +69,9 @@ export type ServiceFactory<TService = unknown> = {
*/
export function createServiceRef<T>(options: {
id: string;
defaultFactory?: (service: ServiceRef<T>) => Promise<ServiceFactory<T>>;
defaultFactory?: (
service: ServiceRef<T>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
}): ServiceRef<T> {
const { id, defaultFactory } = options;
return {
@@ -90,10 +94,22 @@ export function createServiceFactory<
TService,
TImpl extends TService,
TDeps extends { [name in string]: unknown },
TOpts extends { [name in string]: unknown } | undefined = undefined,
>(factory: {
service: ServiceRef<TService>;
deps: TypesToServiceRef<TDeps>;
factory(deps: DepsToDepFactories<TDeps>): Promise<FactoryFunc<TImpl>>;
}): ServiceFactory<TService> {
return factory as ServiceFactory<TService>;
factory(
deps: DepsToDepFactories<TDeps>,
options: TOpts,
): Promise<FactoryFunc<TImpl>>;
}): undefined extends TOpts
? (options?: TOpts) => ServiceFactory<TService>
: (options: TOpts) => ServiceFactory<TService> {
return (options?: TOpts) => ({
service: factory.service,
deps: factory.deps,
factory(deps: DepsToDepFactories<TDeps>) {
return factory.factory(deps, options!);
},
});
}
@@ -46,6 +46,7 @@ export interface TestBackendOptions<
...{
[index in keyof TServices]:
| ServiceFactory<TServices[index]>
| (() => ServiceFactory<TServices[index]>)
| [ServiceRef<TServices[index]>, Partial<TServices[index]>];
},
];
@@ -32,6 +32,7 @@ export interface TestBackendOptions<
...{
[index in keyof TServices]:
| ServiceFactory<TServices[index]>
| (() => ServiceFactory<TServices[index]>)
| [ServiceRef<TServices[index]>, Partial<TServices[index]>];
},
];
+21
View File
@@ -5,6 +5,7 @@
```ts
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Entity } from '@backstage/catalog-model';
import { SerializedError } from '@backstage/errors';
// @public
export type AddLocationRequest = {
@@ -65,6 +66,11 @@ export interface CatalogApi {
id: string,
options?: CatalogRequestOptions,
): Promise<void>;
validateEntity(
entity: Entity,
location: string,
options?: CatalogRequestOptions,
): Promise<ValidateEntityResponse>;
}
// @public
@@ -122,6 +128,11 @@ export class CatalogClient implements CatalogApi {
id: string,
options?: CatalogRequestOptions,
): Promise<void>;
validateEntity(
entity: Entity,
location: string,
options?: CatalogRequestOptions,
): Promise<ValidateEntityResponse>;
}
// @public
@@ -196,4 +207,14 @@ type Location_2 = {
target: string;
};
export { Location_2 as Location };
// @public
export type ValidateEntityResponse =
| {
valid: true;
}
| {
valid: false;
errors: SerializedError[];
};
```
@@ -295,4 +295,87 @@ describe('CatalogClient', () => {
await client.getLocationById('42');
});
});
describe('validateEntity', () => {
it('returns valid false when validation fails', async () => {
server.use(
rest.post(`${mockBaseUrl}/validate-entity`, (_req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
errors: [
{
message: 'Missing name',
},
],
}),
);
}),
);
expect(
await client.validateEntity(
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: '',
},
},
'http://example.com',
),
).toMatchObject({
valid: false,
errors: [
{
message: 'Missing name',
},
],
});
});
it('returns valid true when validation fails', async () => {
server.use(
rest.post(`${mockBaseUrl}/validate-entity`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.text(''));
}),
);
expect(
await client.validateEntity(
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'good',
},
},
'http://example.com',
),
).toMatchObject({
valid: true,
});
});
it('throws unexpected error', async () => {
server.use(
rest.post(`${mockBaseUrl}/validate-entity`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({}));
}),
);
await expect(() =>
client.validateEntity(
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'good',
},
},
'http://example.com',
),
).rejects.toThrow(/Request failed with 500 Error/);
});
});
});
@@ -36,6 +36,7 @@ import {
Location,
GetEntityFacetsRequest,
GetEntityFacetsResponse,
ValidateEntityResponse,
} from './types/api';
import { DiscoveryApi } from './types/discovery';
import { FetchApi } from './types/fetch';
@@ -353,6 +354,44 @@ export class CatalogClient implements CatalogApi {
);
}
/**
* {@inheritdoc CatalogApi.validateEntity}
*/
async validateEntity(
entity: Entity,
location: string,
options?: CatalogRequestOptions,
): Promise<ValidateEntityResponse> {
const response = await this.fetchApi.fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/validate-entity`,
{
headers: {
'Content-Type': 'application/json',
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
},
method: 'POST',
body: JSON.stringify({ entity, location }),
},
);
if (response.ok) {
return {
valid: true,
};
}
if (response.status !== 400) {
throw await ResponseError.fromResponse(response);
}
const { errors = [] } = await response.json();
return {
valid: false,
errors,
};
}
//
// Private methods
//
+22
View File
@@ -15,6 +15,7 @@
*/
import { CompoundEntityRef, Entity } from '@backstage/catalog-model';
import { SerializedError } from '@backstage/errors';
/**
* This symbol can be used in place of a value when passed to filters in e.g.
@@ -269,6 +270,15 @@ export type AddLocationResponse = {
exists?: boolean;
};
/**
* The response type for {@link CatalogClient.validateEntity}
*
* @public
*/
export type ValidateEntityResponse =
| { valid: true }
| { valid: false; errors: SerializedError[] };
/**
* A client for interacting with the Backstage software catalog through its API.
*
@@ -390,4 +400,16 @@ export interface CatalogApi {
id: string,
options?: CatalogRequestOptions,
): Promise<void>;
/**
* Validate entity and its location.
*
* @param entity - Entity to validate
* @param location - URL location of the entity
*/
validateEntity(
entity: Entity,
location: string,
options?: CatalogRequestOptions,
): Promise<ValidateEntityResponse>;
}
@@ -27,5 +27,6 @@ export type {
Location,
GetEntityFacetsRequest,
GetEntityFacetsResponse,
ValidateEntityResponse,
} from './api';
export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status';
+29 -96
View File
@@ -28,6 +28,7 @@ import {
import { YarnInfoInspectData } from '../../lib/versioning/packages';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { NotFoundError } from '@backstage/errors';
// Remove log coloring to simplify log matching
jest.mock('chalk', () => ({
@@ -54,7 +55,15 @@ jest.mock('ora', () => ({
jest.mock('../../lib/run', () => {
return {
run: jest.fn(),
runPlain: jest.fn(),
};
});
const mockFetchPackageInfo = jest.fn();
jest.mock('../../lib/versioning/packages', () => {
const actual = jest.requireActual('../../lib/versioning/packages');
return {
...actual,
fetchPackageInfo: (name: string) => mockFetchPackageInfo(name),
};
});
@@ -105,6 +114,14 @@ const lockfileMockResult = `${HEADER}
`;
describe('bump', () => {
beforeEach(() => {
mockFetchPackageInfo.mockImplementation(async name => ({
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
}));
});
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
@@ -138,17 +155,6 @@ describe('bump', () => {
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
},
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
@@ -184,19 +190,10 @@ describe('bump', () => {
'Version bump complete!',
]);
expect(runObj.runPlain).toHaveBeenCalledTimes(3);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/core',
);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/theme',
);
expect(mockFetchPackageInfo).toHaveBeenCalledTimes(3);
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core');
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core-api');
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/theme');
expect(runObj.run).toHaveBeenCalledTimes(1);
expect(runObj.run).toHaveBeenCalledWith(
@@ -251,17 +248,6 @@ describe('bump', () => {
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
},
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
@@ -309,19 +295,9 @@ describe('bump', () => {
'Version bump complete!',
]);
expect(runObj.runPlain).toHaveBeenCalledTimes(2);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/core',
);
expect(runObj.runPlain).not.toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/theme',
);
expect(mockFetchPackageInfo).toHaveBeenCalledTimes(2);
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core');
expect(mockFetchPackageInfo).not.toHaveBeenCalledWith('@backstage/theme');
expect(runObj.run).toHaveBeenCalledTimes(1);
expect(runObj.run).toHaveBeenCalledWith(
@@ -372,17 +348,6 @@ describe('bump', () => {
},
}),
});
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
},
}),
);
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
@@ -480,17 +445,6 @@ describe('bump', () => {
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
},
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
@@ -614,17 +568,6 @@ describe('bump', () => {
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
},
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
@@ -672,19 +615,9 @@ describe('bump', () => {
'Version bump complete!',
]);
expect(runObj.runPlain).toHaveBeenCalledTimes(5);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/core',
);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/theme',
);
expect(mockFetchPackageInfo).toHaveBeenCalledTimes(5);
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core');
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/theme');
expect(runObj.run).toHaveBeenCalledTimes(1);
expect(runObj.run).toHaveBeenCalledWith(
@@ -743,7 +676,7 @@ describe('bump', () => {
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async () => '');
mockFetchPackageInfo.mockRejectedValue(new NotFoundError('Nope'));
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
+1 -1
View File
@@ -25,7 +25,7 @@ import { promisify } from 'util';
import { LogFunc } from './logging';
import { assertError, ForwardedError } from '@backstage/errors';
const execFile = promisify(execFileCb);
export const execFile = promisify(execFileCb);
type SpawnOptionsPartialEnv = Omit<SpawnOptions, 'env'> & {
env?: Partial<NodeJS.ProcessEnv>;
@@ -17,13 +17,20 @@
import mockFs from 'mock-fs';
import path from 'path';
import * as runObj from '../run';
import * as yarn from '../yarn';
import { fetchPackageInfo, mapDependencies } from './packages';
import { NotFoundError } from '../errors';
jest.mock('../run', () => {
return {
run: jest.fn(),
runPlain: jest.fn(),
execFile: jest.fn(),
};
});
jest.mock('../yarn', () => {
return {
detectYarnVersion: jest.fn(),
};
});
@@ -32,24 +39,55 @@ describe('fetchPackageInfo', () => {
jest.resetAllMocks();
});
it('should forward info', async () => {
jest
.spyOn(runObj, 'runPlain')
.mockResolvedValue(`{"type":"inspect","data":{"the":"data"}}`);
it('should forward info for yarn classic', async () => {
jest.spyOn(runObj, 'execFile').mockResolvedValue({
stdout: `{"type":"inspect","data":{"the":"data"}}`,
stderr: '',
});
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic');
await expect(fetchPackageInfo('my-package')).resolves.toEqual({
the: 'data',
});
expect(runObj.runPlain).toHaveBeenCalledWith(
expect(runObj.execFile).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'my-package',
['info', '--json', 'my-package'],
{ shell: true },
);
});
it('should throw if no info', async () => {
jest.spyOn(runObj, 'runPlain').mockResolvedValue('');
it('should forward info for yarn berry', async () => {
jest
.spyOn(runObj, 'execFile')
.mockResolvedValue({ stdout: `{"the":"data"}`, stderr: '' });
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry');
await expect(fetchPackageInfo('my-package')).resolves.toEqual({
the: 'data',
});
expect(runObj.execFile).toHaveBeenCalledWith(
'yarn',
['npm', 'info', '--json', 'my-package'],
{ shell: true },
);
});
it('should throw if no info with yarn classic', async () => {
jest
.spyOn(runObj, 'execFile')
.mockResolvedValue({ stdout: '', stderr: '' });
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic');
await expect(fetchPackageInfo('my-package')).rejects.toThrow(
new NotFoundError(`No package information found for package my-package`),
);
});
it('should throw if no info with yarn berry', async () => {
jest
.spyOn(runObj, 'execFile')
.mockRejectedValue({ stdout: 'bla bla bla Response Code: 404 bla bla' });
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry');
await expect(fetchPackageInfo('my-package')).rejects.toThrow(
new NotFoundError(`No package information found for package my-package`),
+40 -11
View File
@@ -13,10 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import minimatch from 'minimatch';
import { getPackages } from '@manypkg/get-packages';
import { runPlain } from '../../lib/run';
import { NotFoundError } from '../errors';
import { detectYarnVersion } from '../yarn';
import { execFile } from '../run';
const DEP_TYPES = [
'dependencies',
@@ -48,18 +50,45 @@ type PkgVersionInfo = {
export async function fetchPackageInfo(
name: string,
): Promise<YarnInfoInspectData> {
const output = await runPlain('yarn', 'info', '--json', name);
const yarnVersion = await detectYarnVersion();
if (!output) {
throw new NotFoundError(`No package information found for package ${name}`);
const cmd = yarnVersion === 'classic' ? ['info'] : ['npm', 'info'];
try {
const { stdout: output } = await execFile(
'yarn',
[...cmd, '--json', name],
{ shell: true },
);
if (!output) {
throw new NotFoundError(
`No package information found for package ${name}`,
);
}
if (yarnVersion === 'berry') {
return JSON.parse(output) as YarnInfoInspectData;
}
const info = JSON.parse(output) as YarnInfo;
if (info.type !== 'inspect') {
throw new Error(`Received unknown yarn info for ${name}, ${output}`);
}
return info.data as YarnInfoInspectData;
} catch (error) {
if (yarnVersion === 'classic') {
throw error;
}
if (error?.stdout.includes('Response Code: 404')) {
throw new NotFoundError(
`No package information found for package ${name}`,
);
}
throw error;
}
const info = JSON.parse(output) as YarnInfo;
if (info.type !== 'inspect') {
throw new Error(`Received unknown yarn info for ${name}, ${output}`);
}
return info.data as YarnInfoInspectData;
}
/** Map all dependencies in the repo as dependency => dependents */
+49
View File
@@ -0,0 +1,49 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { assertError, ForwardedError } from '@backstage/errors';
import { execFile as execFileCb } from 'child_process';
import { promisify } from 'util';
const execFile = promisify(execFileCb);
const versions = new Map<string, Promise<'classic' | 'berry'>>();
export function detectYarnVersion(dir?: string): Promise<'classic' | 'berry'> {
const cwd = dir ?? process.cwd();
if (versions.has(cwd)) {
return versions.get(cwd)!;
}
const promise = Promise.resolve().then(async () => {
try {
const { stdout } = await execFile('yarn', ['--version'], {
shell: true,
cwd,
});
return stdout.trim().startsWith('1.') ? 'classic' : 'berry';
} catch (error) {
assertError(error);
if ('stderr' in error) {
process.stderr.write(error.stderr as Buffer);
}
throw new ForwardedError('Failed to determine yarn version', error);
}
});
versions.set(cwd, promise);
return promise;
}
+11 -4
View File
@@ -46,16 +46,23 @@ import { Box } from '@material-ui/core';
import BookmarkIcon from '@material-ui/icons/Bookmark';
import React, { ComponentType, ReactNode } from 'react';
import ReactDOM from 'react-dom';
import { Route } from 'react-router';
import { createRoutesFromChildren, Route } from 'react-router';
import { SidebarThemeSwitcher } from './SidebarThemeSwitcher';
const GatheringRoute: (props: {
export function isReactRouterBeta(): boolean {
const [obj] = createRoutesFromChildren(<Route index element={<div />} />);
return !obj.index;
}
const MaybeGatheringRoute: (props: {
path: string;
element: JSX.Element;
children?: ReactNode;
}) => JSX.Element = ({ element }) => element;
attachComponentData(GatheringRoute, 'core.gatherMountPoints', true);
if (isReactRouterBeta()) {
attachComponentData(MaybeGatheringRoute, 'core.gatherMountPoints', true);
}
/** @public */
export type DevAppPageOptions = {
@@ -136,7 +143,7 @@ export class DevAppBuilder {
);
}
this.routes.push(
<GatheringRoute
<MaybeGatheringRoute
key={path}
path={path}
element={opts.element}
@@ -57,6 +57,7 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => {
token: 'hardcoded_token',
});
});
it('create repository specific tokens', async () => {
octokit.apps.listInstallations.mockResolvedValue({
headers: {
@@ -66,11 +67,6 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => {
{
id: 1,
repository_selection: 'selected',
account: null,
},
{
id: 2,
repository_selection: 'selected',
account: {
login: 'backstage',
},
@@ -195,9 +191,14 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => {
data: {
expires_at: DateTime.local().plus({ hours: 1 }).toString(),
token: 'secret_token',
repository_selection: 'selected',
},
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
octokit.apps.listReposAccessibleToInstallation.mockReturnValue({
data: [{ name: 'some-repo' }],
} as unknown as RestEndpointMethodTypes['apps']['listReposAccessibleToInstallation']['response']);
const { token, headers } = await github.getCredentials({
url: 'https://github.com/backstage',
});
@@ -400,4 +401,70 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => {
}),
).resolves.not.toThrow();
});
it('should cache access token', async () => {
octokit.apps.listInstallations.mockReturnValue({
headers: {
etag: '123',
},
data: [
{
id: 1,
repository_selection: 'all',
account: {
login: 'backstage',
},
},
],
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
octokit.apps.createInstallationAccessToken.mockReturnValue({
data: {
expires_at: DateTime.local().plus({ minutes: 11 }).toString(),
token: 'secret_token',
},
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
await github.getCredentials({ url: 'https://github.com/backstage' });
await github.getCredentials({ url: 'https://github.com/backstage' });
expect(octokit.apps.listInstallations.mock.calls.length).toBe(1);
expect(octokit.apps.createInstallationAccessToken.mock.calls.length).toBe(
1,
);
});
it('should expire access token cache when less than 10 mins before token expires', async () => {
octokit.apps.listInstallations.mockReturnValue({
headers: {
etag: '123',
},
data: [
{
id: 1,
repository_selection: 'all',
account: {
login: 'backstage',
},
},
],
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
octokit.apps.createInstallationAccessToken.mockReturnValue({
data: {
expires_at: DateTime.local()
.plus({ minutes: 9, seconds: 59, milliseconds: 999 })
.toString(),
token: 'secret_token',
},
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
await github.getCredentials({ url: 'https://github.com/backstage' });
await github.getCredentials({ url: 'https://github.com/backstage' });
expect(octokit.apps.listInstallations.mock.calls.length).toBe(2);
expect(octokit.apps.createInstallationAccessToken.mock.calls.length).toBe(
2,
);
});
});
@@ -30,29 +30,56 @@ type InstallationData = {
suspended: boolean;
};
type InstallationTokenData = {
token: string;
expiresAt: DateTime;
repositories?: String[];
};
class Cache {
private readonly tokenCache = new Map<
string,
{ token: string; expiresAt: DateTime }
>();
private readonly tokenCache = new Map<string, InstallationTokenData>();
async getOrCreateToken(
key: string,
supplier: () => Promise<{ token: string; expiresAt: DateTime }>,
owner: string,
repo: string | undefined,
supplier: () => Promise<InstallationTokenData>,
): Promise<{ accessToken: string }> {
const item = this.tokenCache.get(key);
if (item && this.isNotExpired(item.expiresAt)) {
return { accessToken: item.token };
let existingInstallationData = this.tokenCache.get(owner);
if (
!existingInstallationData ||
this.isExpired(existingInstallationData.expiresAt)
) {
existingInstallationData = await supplier();
// Allow 10 minutes grace to account for clock skew
existingInstallationData.expiresAt =
existingInstallationData.expiresAt.minus({ minutes: 10 });
this.tokenCache.set(owner, existingInstallationData);
}
const result = await supplier();
this.tokenCache.set(key, result);
return { accessToken: result.token };
if (!this.appliesToRepo(existingInstallationData, repo)) {
throw new Error(
`The Backstage GitHub application used in the ${owner} organization does not have access to a repository with the name ${repo}`,
);
}
return { accessToken: existingInstallationData.token };
}
// consider timestamps older than 50 minutes to be expired.
private isNotExpired = (date: DateTime) =>
date.diff(DateTime.local(), 'minutes').minutes > 50;
private isExpired = (date: DateTime) => DateTime.local() > date;
private appliesToRepo(tokenData: InstallationTokenData, repo?: string) {
// If no specific repo has been requested the token is applicable
if (repo === undefined) {
return true;
}
// If the token is restricted to repositories, the token only applies if the repo is in the allow list
if (tokenData.repositories !== undefined) {
return tokenData.repositories.includes(repo);
}
// Otherwise the token is applicable
return true;
}
}
/**
@@ -93,25 +120,29 @@ class GithubAppManager {
owner: string,
repo?: string,
): Promise<{ accessToken: string | undefined }> {
const { installationId, suspended } = await this.getInstallationData(owner);
if (this.allowedInstallationOwners) {
if (!this.allowedInstallationOwners?.includes(owner)) {
return { accessToken: undefined }; // An empty token allows anonymous access to public repos
}
}
if (suspended) {
throw new Error(`The GitHub application for ${owner} is suspended`);
}
const cacheKey = repo ? `${owner}/${repo}` : owner;
// Go and grab an access token for the app scoped to a repository if provided, if not use the organisation installation.
return this.cache.getOrCreateToken(cacheKey, async () => {
return this.cache.getOrCreateToken(owner, repo, async () => {
const { installationId, suspended } = await this.getInstallationData(
owner,
);
if (suspended) {
throw new Error(`The GitHub application for ${owner} is suspended`);
}
const result = await this.appClient.apps.createInstallationAccessToken({
installation_id: installationId,
headers: HEADERS,
});
if (repo && result.data.repository_selection === 'selected') {
let repositoryNames;
if (result.data.repository_selection === 'selected') {
const installationClient = new Octokit({
baseUrl: this.baseUrl,
auth: result.data.token,
@@ -122,18 +153,13 @@ class GithubAppManager {
// The return type of the paginate method is incorrect.
const repositories: RestEndpointMethodTypes['apps']['listReposAccessibleToInstallation']['response']['data']['repositories'] =
repos.repositories ?? repos;
const hasRepo = repositories.some(repository => {
return repository.name === repo;
});
if (!hasRepo) {
throw new Error(
`The Backstage GitHub application used in the ${owner} organization does not have access to a repository with the name ${repo}`,
);
}
repositoryNames = repositories.map(repository => repository.name);
}
return {
token: result.data.token,
expiresAt: DateTime.fromISO(result.data.expires_at),
repositories: repositoryNames,
};
});
}
+1
View File
@@ -50,6 +50,7 @@
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0",
"react-dom": "^16.13.1 || ^17.0.0",
"react-router": "6.0.0-beta.0 || ^6.3.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
+2
View File
@@ -62,6 +62,7 @@
"node-fetch": "^2.6.7",
"openid-client": "^5.1.3",
"passport": "^0.6.0",
"passport-auth0": "^1.4.3",
"passport-bitbucket-oauth2": "^0.1.2",
"passport-github2": "^0.1.12",
"passport-gitlab2": "^5.0.0",
@@ -81,6 +82,7 @@
"@types/cookie-parser": "^1.4.2",
"@types/express-session": "^1.17.2",
"@types/jwt-decode": "^3.1.0",
"@types/passport-auth0": "^1.0.5",
"@types/passport-github2": "^1.2.4",
"@types/passport-google-oauth20": "^2.0.3",
"@types/passport-microsoft": "^0.0.0",
@@ -35,6 +35,7 @@ describe('CatalogIdentityClient', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const tokenManager: jest.Mocked<TokenManager> = {
getToken: jest.fn(),
@@ -91,6 +91,7 @@ export const executeRedirectStrategy = async (
export const executeFrameHandlerStrategy = async <Result, PrivateInfo = never>(
req: express.Request,
providerStrategy: passport.Strategy,
options?: Record<string, string>,
) => {
return new Promise<{ result: Result; privateInfo: PrivateInfo }>(
(resolve, reject) => {
@@ -124,7 +125,7 @@ export const executeFrameHandlerStrategy = async <Result, PrivateInfo = never>(
strategy.redirect = () => {
reject(new Error('Unexpected redirect'));
};
strategy.authenticate(req, {});
strategy.authenticate(req, { ...(options ?? {}) });
},
);
};
@@ -43,6 +43,7 @@ import {
AuthResolverContext,
} from '../types';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { StateStore } from 'passport-oauth2';
type PrivateInfo = {
refreshToken: string;
@@ -53,6 +54,7 @@ export type Auth0AuthProviderOptions = OAuthProviderOptions & {
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
resolverContext: AuthResolverContext;
audience?: string;
};
export class Auth0AuthProvider implements OAuthHandlers {
@@ -60,11 +62,30 @@ export class Auth0AuthProvider implements OAuthHandlers {
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly resolverContext: AuthResolverContext;
private readonly audience?: string;
/**
* Due to passport-auth0 forcing options.state = true,
* passport-oauth2 requires express-session to be installed
* so that the 'state' parameter of the oauth2 flow can be stored.
* This implementation of StateStore matches the NullStore found within
* passport-oauth2, which is the StateStore implementation used when options.state = false,
* allowing us to avoid using express-session in order to integrate with auth0.
*/
private store: StateStore = {
store(_req: express.Request, cb: any) {
cb(null, null);
},
verify(_req: express.Request, _state: string, cb: any) {
cb(null, true);
},
};
constructor(options: Auth0AuthProviderOptions) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.resolverContext = options.resolverContext;
this.audience = options.audience;
this._strategy = new Auth0Strategy(
{
clientID: options.clientId,
@@ -74,6 +95,7 @@ export class Auth0AuthProvider implements OAuthHandlers {
// We need passReqToCallback set to false to get params, but there's
// no matching type signature for that, so instead behold this beauty
passReqToCallback: false as true,
store: this.store,
},
(
accessToken: any,
@@ -104,6 +126,7 @@ export class Auth0AuthProvider implements OAuthHandlers {
prompt: 'consent',
scope: req.scope,
state: encodeState(req.state),
...(this.audience ? { audience: this.audience } : {}),
});
}
@@ -111,7 +134,9 @@ export class Auth0AuthProvider implements OAuthHandlers {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
>(req, this._strategy);
>(req, this._strategy, {
...(this.audience ? { audience: this.audience } : {}),
});
return {
response: await this.handleResult(result),
@@ -198,6 +223,7 @@ export const auth0 = createAuthProviderIntegration({
const clientSecret = envConfig.getString('clientSecret');
const domain = envConfig.getString('domain');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const audience = envConfig.getOptionalString('audience');
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
@@ -218,6 +244,7 @@ export const auth0 = createAuthProviderIntegration({
authHandler,
signInResolver,
resolverContext,
audience,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -13,7 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import OAuth2Strategy from 'passport-oauth2';
import Auth0InternalStrategy from 'passport-auth0';
import { StateStore } from 'passport-oauth2';
export interface Auth0StrategyOptionsWithRequest {
clientID: string;
@@ -21,12 +22,13 @@ export interface Auth0StrategyOptionsWithRequest {
callbackURL: string;
domain: string;
passReqToCallback: true;
store: StateStore;
}
export default class Auth0Strategy extends OAuth2Strategy {
export default class Auth0Strategy extends Auth0InternalStrategy {
constructor(
options: Auth0StrategyOptionsWithRequest,
verify: OAuth2Strategy.VerifyFunctionWithRequest,
verify: Auth0InternalStrategy.VerifyFunction,
) {
const optionsWithURLs = {
...options,
@@ -68,6 +68,7 @@ describe('createRouter', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
config = new ConfigReader({
+1 -2
View File
@@ -41,8 +41,7 @@
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.19.0-next.1",
@@ -16,7 +16,6 @@
import React from 'react';
import { Header, RoutedTabs } from '@backstage/core-components';
import { Route } from 'react-router-dom';
import { SortView } from '../SortView';
import { About } from '../About';
@@ -25,12 +24,12 @@ export const HomePage = () => {
{
path: '/',
title: 'Home',
children: <Route path="/" element={<SortView />} />,
children: <SortView />,
},
{
path: '/about',
title: 'About',
children: <Route path="/about" element={<About />} />,
children: <About />,
},
];
@@ -100,6 +100,7 @@ describe('AwsS3EntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = AwsS3EntityProvider.fromConfig(config, {
@@ -71,6 +71,7 @@ describe('AzureDevOpsEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = AzureDevOpsEntityProvider.fromConfig(config, {
@@ -127,6 +127,7 @@ describe('BitbucketCloudEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = BitbucketCloudEntityProvider.fromConfig(config, {
logger,
@@ -221,6 +221,7 @@ describe('BitbucketServerEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = BitbucketServerEntityProvider.fromConfig(config, {
logger,
@@ -296,6 +297,7 @@ describe('BitbucketServerEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = BitbucketServerEntityProvider.fromConfig(config, {
logger,
@@ -83,6 +83,7 @@ describe('GerritEntityProvider', () => {
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
it('discovers projects from the api.', async () => {
@@ -146,6 +146,7 @@ describe('GitHubEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitHubEntityProvider.fromConfig(config, {
@@ -233,6 +234,7 @@ describe('GitHubEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitHubEntityProvider.fromConfig(config, {
@@ -306,6 +308,7 @@ describe('GitHubEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitHubEntityProvider.fromConfig(config, {
@@ -404,6 +407,7 @@ it('apply full update on scheduled execution with topic exclusion taking priorit
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitHubEntityProvider.fromConfig(config, {
@@ -83,6 +83,7 @@ describe('GitHubOrgEntityProvider', () => {
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const logger = getVoidLogger();
@@ -155,6 +155,7 @@ describe('GitlabDiscoveryEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
@@ -253,6 +254,7 @@ describe('GitlabDiscoveryEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
@@ -95,6 +95,7 @@ describe('MicrosoftGraphOrgEntityProvider', () => {
};
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = MicrosoftGraphOrgEntityProvider.fromConfig(
new ConfigReader(config),
@@ -157,6 +157,14 @@ export interface ProcessingDatabase {
*/
refresh(txOpaque: Transaction, options: RefreshOptions): Promise<void>;
/**
* Schedules a refresh for every entity that has a matching set of refresh key stored for it.
*/
refreshByRefreshKeys(
txOpaque: Transaction,
options: RefreshByKeyOptions,
): Promise<void>;
/**
* Lists all ancestors of a given entityRef.
*
@@ -26,7 +26,7 @@ describe('DefaultLocationStore', () => {
async function createLocationStore(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
await applyDatabaseMigrations(knex);
const connection = { applyMutation: jest.fn() };
const connection = { applyMutation: jest.fn(), refresh: jest.fn() };
const store = new DefaultLocationStore(knex);
await store.connect(connection);
return { store, connection };
@@ -22,6 +22,7 @@ import { ProcessingDatabase } from '../database/types';
import {
EntityProvider,
EntityProviderConnection,
EntityProviderRefreshOptions,
EntityProviderMutation,
} from '@backstage/plugin-catalog-node';
@@ -61,6 +62,16 @@ class Connection implements EntityProviderConnection {
}
}
async refresh(options: EntityProviderRefreshOptions): Promise<void> {
const db = this.config.processingDatabase;
await db.transaction(async (tx: any) => {
return db.refreshByRefreshKeys(tx, {
keys: options.keys,
});
});
}
private check(entities: Entity[]) {
for (const entity of entities) {
try {
@@ -59,6 +59,7 @@ describe('<CatalogGraphCard/>', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
apis = TestApiRegistry.from([catalogApiRef, catalog]);
@@ -92,6 +92,7 @@ describe('<CatalogGraphPage/>', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
wrapper = (
@@ -119,6 +119,7 @@ describe('<EntityRelationsGraph/>', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
Wrapper = ({ children }) => (
@@ -38,6 +38,7 @@ describe('useEntityStore', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
useApi.mockReturnValue(catalogApi);
@@ -100,6 +100,7 @@ describe('CatalogImportClient', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
let catalogImportClient: CatalogImportClient;
@@ -46,6 +46,7 @@ describe('<StepPrepareCreatePullRequest />', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const errorApi: jest.Mocked<typeof errorApiRef.T> = {
+6
View File
@@ -126,6 +126,7 @@ export interface EntityProvider {
// @public
export interface EntityProviderConnection {
applyMutation(mutation: EntityProviderMutation): Promise<void>;
refresh(options: EntityProviderRefreshOptions): Promise<void>;
}
// @public
@@ -140,6 +141,11 @@ export type EntityProviderMutation =
removed: DeferredEntity[];
};
// @public (undocumented)
export type EntityProviderRefreshOptions = {
keys: string[];
};
// @public
export type EntityRelationSpec = {
source: CompoundEntityRef;
+1
View File
@@ -32,4 +32,5 @@ export type {
EntityProvider,
EntityProviderConnection,
EntityProviderMutation,
EntityProviderRefreshOptions,
} from './provider';
+12
View File
@@ -26,6 +26,13 @@ export type EntityProviderMutation =
| { type: 'full'; entities: DeferredEntity[] }
| { type: 'delta'; added: DeferredEntity[]; removed: DeferredEntity[] };
/**
* @public
*/
export type EntityProviderRefreshOptions = {
keys: string[];
};
/**
* The EntityProviderConnection is the connection between the catalog and the entity provider.
* The EntityProvider use this connection to add and remove entities from the catalog.
@@ -36,6 +43,11 @@ export interface EntityProviderConnection {
* Applies either a full or delta update to the catalog engine.
*/
applyMutation(mutation: EntityProviderMutation): Promise<void>;
/**
* Schedules a refresh on all of the entities that has a matching refresh key associated with the provided keys.
*/
refresh(options: EntityProviderRefreshOptions): Promise<void>;
}
/**
+8 -1
View File
@@ -364,7 +364,14 @@ export class EntityTagFilter implements EntityFilter {
}
// @public (undocumented)
export const EntityTagPicker: () => JSX.Element | null;
export const EntityTagPicker: (
props: EntityTagPickerProps,
) => JSX.Element | null;
// @public (undocumented)
export type EntityTagPickerProps = {
showCounts?: boolean;
};
// @public
export class EntityTextFilter implements EntityFilter {
@@ -28,7 +28,9 @@ const tags = ['tag1', 'tag2', 'tag3', 'tag4'];
describe('<EntityTagPicker/>', () => {
const mockCatalogApiRef = {
getEntityFacets: async () => ({
facets: { 'metadata.tags': tags.map(value => ({ value })) },
facets: {
'metadata.tags': tags.map((value, idx) => ({ value, count: idx })),
},
}),
} as unknown as CatalogApi;
@@ -68,6 +70,26 @@ describe('<EntityTagPicker/>', () => {
]);
});
it('renders tags with counts', async () => {
const rendered = render(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApiRef]]}>
<MockEntityListContextProvider value={{}}>
<EntityTagPicker showCounts />
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() => expect(rendered.getByText('Tags')).toBeInTheDocument());
fireEvent.click(rendered.getByTestId('tag-picker-expand'));
expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([
'tag1 (0)',
'tag2 (1)',
'tag3 (2)',
'tag4 (3)',
]);
});
it('respects the query parameter filter value', async () => {
const updateFilters = jest.fn();
const queryParameters = { tags: ['tag3'] };
@@ -49,7 +49,12 @@ const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
/** @public */
export const EntityTagPicker = () => {
export type EntityTagPickerProps = {
showCounts?: boolean;
};
/** @public */
export const EntityTagPicker = (props: EntityTagPickerProps) => {
const classes = useStyles();
const {
updateFilters,
@@ -65,7 +70,9 @@ export const EntityTagPicker = () => {
filter: filters.kind?.getCatalogFilters(),
});
return facets[facet].map(({ value }) => value);
return Object.fromEntries(
facets[facet].map(({ value, count }) => [value, count]),
);
}, [filters.kind]);
const queryParamTags = useMemo(
@@ -91,7 +98,7 @@ export const EntityTagPicker = () => {
});
}, [selectedTags, updateFilters]);
if (!availableTags?.length) return null;
if (!Object.keys(availableTags ?? {}).length) return null;
return (
<Box pb={1} pt={1}>
@@ -99,7 +106,7 @@ export const EntityTagPicker = () => {
Tags
<Autocomplete
multiple
options={availableTags}
options={Object.keys(availableTags ?? {})}
value={selectedTags}
onChange={(_: object, value: string[]) => setSelectedTags(value)}
renderOption={(option, { selected }) => (
@@ -111,7 +118,11 @@ export const EntityTagPicker = () => {
checked={selected}
/>
}
label={option}
label={
props.showCounts
? `${option} (${availableTags?.[option]})`
: option
}
/>
)}
size="small"
@@ -15,4 +15,7 @@
*/
export { EntityTagPicker } from './EntityTagPicker';
export type { CatalogReactEntityTagPickerClassKey } from './EntityTagPicker';
export type {
CatalogReactEntityTagPickerClassKey,
EntityTagPickerProps,
} from './EntityTagPicker';
@@ -32,6 +32,7 @@ describe('<DefaultExplorePage />', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -33,6 +33,7 @@ describe('<DomainExplorerContent />', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -33,6 +33,7 @@ describe('<GroupsExplorerContent />', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -37,6 +37,7 @@ describe('<FossaPage />', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const fossaApi: jest.Mocked<FossaApi> = {
getFindingSummary: jest.fn(),
+10
View File
@@ -0,0 +1,10 @@
# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
version: v1.22.1
# ignores vulnerabilities until expiry date; change duration by modifying expiry date
ignore:
SNYK-JS-TAFFYDB-2992450:
- '*':
reason: Development dependency only
expires: 2023-03-07T20:23:00.000Z
created: 2022-09-07T20:23:00.000Z
patch: {}
@@ -0,0 +1,39 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Box } from '@material-ui/core';
import React, { useEffect, useRef } from 'react';
import { DismissableBanner } from '@backstage/core-components';
type TaskErrorsProps = {
error?: Error;
};
export const TaskErrors = ({ error }: TaskErrorsProps) => {
const id = useRef('');
useEffect(() => {
id.current = String(Math.random());
}, [error]);
return error ? (
<Box>
<DismissableBanner
id={id.current}
variant="warning"
message={error.message}
/>
</Box>
) : null;
};
@@ -54,6 +54,7 @@ import {
} from '../../routes';
import { ScaffolderTaskStatus, ScaffolderTaskOutput } from '../../types';
import { useTaskEventStream } from '../hooks/useEventStream';
import { TaskErrors } from './TaskErrors';
import { TaskPageLinks } from './TaskPageLinks';
// typings are wrong for this library, so fallback to not parsing types.
@@ -356,6 +357,7 @@ export const TaskPage = ({ loadingText }: TaskPageProps) => {
{!currentStepId && <Progress />}
<div style={{ height: '80vh' }}>
<TaskErrors error={taskStream.error} />
<LogViewer text={logAsString} />
</div>
</Grid>
@@ -49,6 +49,7 @@ type ReducerLogEntry = {
status?: ScaffolderTaskStatus;
message: string;
output?: ScaffolderTaskOutput;
error?: Error;
};
};
@@ -114,6 +115,8 @@ function reducer(draft: TaskStream, action: ReducerAction) {
case 'COMPLETED': {
draft.completed = true;
draft.output = action.data.body.output;
draft.error = action.data.body.error;
return;
}
@@ -53,6 +53,7 @@ function mockCatalogClient(entity?: Entity): jest.Mocked<CatalogApi> {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
if (entity) {
mock.getEntityByRef.mockReturnValue(entity);
+118 -71
View File
@@ -2833,6 +2833,8 @@ __metadata:
"@types/react": ^16.13.1 || ^17.0.0
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
react-router: 6.0.0-beta.0 || ^6.3.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft
@@ -3946,6 +3948,7 @@ __metadata:
"@types/express-session": ^1.17.2
"@types/jwt-decode": ^3.1.0
"@types/passport": ^1.0.3
"@types/passport-auth0": ^1.0.5
"@types/passport-github2": ^1.2.4
"@types/passport-google-oauth20": ^2.0.3
"@types/passport-microsoft": ^0.0.0
@@ -3972,6 +3975,7 @@ __metadata:
node-fetch: ^2.6.7
openid-client: ^5.1.3
passport: ^0.6.0
passport-auth0: ^1.4.3
passport-bitbucket-oauth2: ^0.1.2
passport-github2: ^0.1.12
passport-gitlab2: ^5.0.0
@@ -4164,7 +4168,6 @@ __metadata:
react-use: ^17.2.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft
@@ -7421,6 +7424,7 @@ __metadata:
peerDependencies:
"@types/react": ^16.13.1 || ^17.0.0
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
react-router: 6.0.0-beta.0 || ^6.3.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
@@ -8088,11 +8092,11 @@ __metadata:
linkType: hard
"@google-cloud/container@npm:^4.0.0":
version: 4.1.1
resolution: "@google-cloud/container@npm:4.1.1"
version: 4.1.2
resolution: "@google-cloud/container@npm:4.1.2"
dependencies:
google-gax: ^3.3.0
checksum: 85c124163be7300c57b1f35323ea48a25c398bf072497a3e4a5d9117fe44547a63071dced681dbe8d2bd1a0997588d36c2428900184b030daf29b0f87ec7b09c
checksum: deb1b88732b2dd3dba5f7bea48fd672636fedb0d3bd894e29c8d097e087de3bfd30b53668e3686f8d879b3312be2e86d5a65cd1ba6bf630ba1c2bbe892e8bbb8
languageName: node
linkType: hard
@@ -8678,15 +8682,15 @@ __metadata:
languageName: node
linkType: hard
"@graphql-tools/merge@npm:8.3.5":
version: 8.3.5
resolution: "@graphql-tools/merge@npm:8.3.5"
"@graphql-tools/merge@npm:8.3.6":
version: 8.3.6
resolution: "@graphql-tools/merge@npm:8.3.6"
dependencies:
"@graphql-tools/utils": 8.11.0
"@graphql-tools/utils": 8.12.0
tslib: ^2.4.0
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: c834619acabe0587d7528a795e2e7527b09e528486b965f4638f2bd831e0c1ac6439fb22e82d3512329cf293f69bca4db0e4696a5aac88748a09cb6de6597fb4
checksum: 3e45ebff0dce9524e72c4af1d2b9799c16515c1236290e07cd68fb2226c4e54734e2444d89a64b387b7ba991d15c6f948fdfc20c77a74b1d24babddd865ff32a
languageName: node
linkType: hard
@@ -8800,16 +8804,16 @@ __metadata:
linkType: hard
"@graphql-tools/schema@npm:^9.0.0":
version: 9.0.3
resolution: "@graphql-tools/schema@npm:9.0.3"
version: 9.0.4
resolution: "@graphql-tools/schema@npm:9.0.4"
dependencies:
"@graphql-tools/merge": 8.3.5
"@graphql-tools/utils": 8.11.0
"@graphql-tools/merge": 8.3.6
"@graphql-tools/utils": 8.12.0
tslib: ^2.4.0
value-or-promise: 1.0.11
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: 7ce960e3af637de592f7859db5841e3f0974997bd48767efab163bbafdc88a40e95a789f1f7cb7a178fb53b89f5a5bc881af5f8d796c096ce09386340b85ca4f
checksum: 0644ba225ff7fb03c6fb7f026b6a77a4ac5dd14fd10bb562ea0072bfe0258620fd4789b851b0b97007db8754d0b7d88a1061bb98bacc679b22e2eb7706e79e0e
languageName: node
linkType: hard
@@ -8863,14 +8867,14 @@ __metadata:
languageName: node
linkType: hard
"@graphql-tools/utils@npm:8.11.0":
version: 8.11.0
resolution: "@graphql-tools/utils@npm:8.11.0"
"@graphql-tools/utils@npm:8.12.0":
version: 8.12.0
resolution: "@graphql-tools/utils@npm:8.12.0"
dependencies:
tslib: ^2.4.0
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: 2a223ba056c3c3851defa87a4e3a83fe523e5946f63ccdc8a181c7b290abcc9c3712cb0fb6a33edc8ae072c6530e4b667dc822a8debd9f80fea937a5b253eb26
checksum: 24edc6ba3bcfa9a4c1d1d37117c3f96d847beed9638325083c32c6ec9674729dc89fc8cc389d317ae5d9dba22e91443bd9788f1dc8de91a1b6f1e592112bd48f
languageName: node
linkType: hard
@@ -12489,9 +12493,9 @@ __metadata:
languageName: node
linkType: hard
"@roadiehq/backstage-plugin-buildkite@npm:^2.0.0":
version: 2.0.7
resolution: "@roadiehq/backstage-plugin-buildkite@npm:2.0.7"
"@roadiehq/backstage-plugin-buildkite@npm:^2.0.8":
version: 2.0.8
resolution: "@roadiehq/backstage-plugin-buildkite@npm:2.0.8"
dependencies:
"@backstage/catalog-model": ^1.0.0
"@backstage/core-components": ^0.11.0
@@ -12503,19 +12507,19 @@ __metadata:
"@material-ui/lab": 4.0.0-alpha.57
history: ^5.0.0
moment: ^2.29.1
react-router: 6.0.0-beta.0
react-router-dom: 6.0.0-beta.0
react-use: ^17.2.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
checksum: cf7ba623c418d3ea7ea56c67b0fe10d6be7fe36531539c31e87b586897fcb9c3474a432239d7d6c01623ff6fc29fb4ae905564a9d2aae11e2ce54b8502cc1c29
react-router: 6.0.0-beta.0 || ^6.3.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
checksum: 589f89b53ff166ca9099ea8d2a5b6f96b87a3daf2e0cb115e94c8880639f69bc25a04cab2926cba0c99dc9bb70a159528c6f1fc10a9fba67e4fb3abe40bce0bf
languageName: node
linkType: hard
"@roadiehq/backstage-plugin-github-insights@npm:^2.0.0":
version: 2.0.4
resolution: "@roadiehq/backstage-plugin-github-insights@npm:2.0.4"
"@roadiehq/backstage-plugin-github-insights@npm:^2.0.5":
version: 2.0.5
resolution: "@roadiehq/backstage-plugin-github-insights@npm:2.0.5"
dependencies:
"@backstage/catalog-model": ^1.0.0
"@backstage/core-components": ^0.11.0
@@ -12531,19 +12535,19 @@ __metadata:
"@octokit/types": ^6.14.2
history: ^5.0.0
immer: 9.0.7
react-router: ^6.0.0-beta.0
react-use: ^17.2.4
zustand: 3.6.9
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
checksum: 2cc6eb992e78d7ce32ecce09996bda4eb34d4af3564ab54d180543d3a195a8b6e93f3137724569c49e645d44bfc00f3802285418f690393c456700668be01c25
react-router: 6.0.0-beta.0 || ^6.3.0
checksum: 37f99d0478dee6acbbafaf334a2eb1f75c251e96f8b10cead0d6f6dc203667dca51ded935ca704900251c01493b6287a66814f79245410a79ad66c843a3d4803
languageName: node
linkType: hard
"@roadiehq/backstage-plugin-github-pull-requests@npm:^2.0.0":
version: 2.2.6
resolution: "@roadiehq/backstage-plugin-github-pull-requests@npm:2.2.6"
"@roadiehq/backstage-plugin-github-pull-requests@npm:^2.2.7":
version: 2.2.7
resolution: "@roadiehq/backstage-plugin-github-pull-requests@npm:2.2.7"
dependencies:
"@backstage/catalog-model": ^1.0.0
"@backstage/core-components": ^0.11.0
@@ -12561,18 +12565,18 @@ __metadata:
moment: ^2.27.0
msw: ^0.39.2
node-fetch: ^2.6.1
react-router: 6.0.0-beta.0
react-use: ^17.2.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
checksum: 1b6d584a1e6cc668026d9bcdea7e5588ad22084412e5e2c52659aed9da0f90fa5edb81e8d90d2ff67b645e0fb96790b91cb0a1024f1682319c7fb4799b62baa8
react-router: 6.0.0-beta.0 || ^6.3.0
checksum: 2453f72c4b853e4d6f43cb27aaf27cbce5d122fbcdc71625b1e1493486acde551798524cc84f792d986397011ab458ba9b7e56eec34cde55660ccd69ea488cb0
languageName: node
linkType: hard
"@roadiehq/backstage-plugin-travis-ci@npm:^2.0.0":
version: 2.0.4
resolution: "@roadiehq/backstage-plugin-travis-ci@npm:2.0.4"
"@roadiehq/backstage-plugin-travis-ci@npm:^2.0.5":
version: 2.0.5
resolution: "@roadiehq/backstage-plugin-travis-ci@npm:2.0.5"
dependencies:
"@backstage/catalog-model": ^1.0.0
"@backstage/core-components": ^0.11.0
@@ -12586,13 +12590,13 @@ __metadata:
date-fns: ^2.18.0
history: ^5.0.0
moment: ^2.29.1
react-router: 6.0.0-beta.0
react-router-dom: 6.0.0-beta.0
react-use: ^17.2.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
checksum: 1c0f3389f6415d23fe1926e3387433da7f3d22b4a237c3a690c894cfa4c281b21307a3ec51a2c48cfa115727cca182c15017ef1e58a2e9b893d89d06df6db35e
react-router: 6.0.0-beta.0 || ^6.3.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
checksum: bafcef69ca1b36585dbafcf8964d897942bd026f74dad81f372aed9ee1ce496a4821570f462b62b1b8d0cb86013615ad39e103827163bde8cf32037143b05c7a
languageName: node
linkType: hard
@@ -14635,6 +14639,16 @@ __metadata:
languageName: node
linkType: hard
"@types/passport-auth0@npm:^1.0.5":
version: 1.0.5
resolution: "@types/passport-auth0@npm:1.0.5"
dependencies:
"@types/express": "*"
"@types/passport": "*"
checksum: b86b69a182864cae6877c0f16a1d62e24a394dc9cde8e7285a29aef89d52fbb555b899e061157bfd52420dcbccd6fd9b189155e5367a22763b0b2813cbb28354
languageName: node
linkType: hard
"@types/passport-github2@npm:^1.2.4":
version: 1.2.5
resolution: "@types/passport-github2@npm:1.2.5"
@@ -15855,12 +15869,12 @@ __metadata:
linkType: hard
"@yarnpkg/parsers@npm:^3.0.0-rc.4":
version: 3.0.0-rc.15
resolution: "@yarnpkg/parsers@npm:3.0.0-rc.15"
version: 3.0.0-rc.18
resolution: "@yarnpkg/parsers@npm:3.0.0-rc.18"
dependencies:
js-yaml: ^3.10.0
tslib: ^2.4.0
checksum: 2c6044479b1a8b5716d1536fce306c3c1f1989e00be5b7072255d0378d8679eba3dd6e3a854e75d8f675ea98a9612e5fbb1c3aac973860e6d5089658d3abf1d7
checksum: 73cc59cb2349ce78a376d6ad7953117360de9aff866f42329a3e957ebabbbd4cbd91bfe79e30aa3bf07f126af66b72c00a7fc733724ac93d7f46d420ba81beec
languageName: node
linkType: hard
@@ -16977,6 +16991,16 @@ __metadata:
languageName: node
linkType: hard
"axios@npm:^0.27.2":
version: 0.27.2
resolution: "axios@npm:0.27.2"
dependencies:
follow-redirects: ^1.14.9
form-data: ^4.0.0
checksum: 38cb7540465fe8c4102850c4368053c21683af85c5fdf0ea619f9628abbcb59415d1e22ebc8a6390d2bbc9b58a9806c874f139767389c862ec9b772235f06854
languageName: node
linkType: hard
"axobject-query@npm:^2.2.0":
version: 2.2.0
resolution: "axobject-query@npm:2.2.0"
@@ -18098,29 +18122,22 @@ __metadata:
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001286":
version: 1.0.30001315
resolution: "caniuse-lite@npm:1.0.30001315"
checksum: 3938596c61e4a5c6e96d1c6aecbde67b31c4c92e55a9d443b8e51e0eac64b6dd848dfac84891f75464f8d25c7e1ed6e9c9640593922b9bd360629fd433fb69e2
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.30001366":
version: 1.0.30001367
resolution: "caniuse-lite@npm:1.0.30001367"
checksum: 9912aed182b8b3a834787424b56a0e71b5ecb5d2cb7235fb022227bc3a81202e8a34bebc5dc9cc504c515b4e052f825c36c2db4b0b880c10e195fe63673edfc6
"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001286, caniuse-lite@npm:^1.0.30001366":
version: 1.0.30001392
resolution: "caniuse-lite@npm:1.0.30001392"
checksum: b46fdb4cd63f43e608c9378e9a4cfc1092407fccda32f13f53db8818e0e654e5b2b9e7aee76b95a1a4fd3f2c2eabd1157aef19905f2c60dfcec136b27ac5fce6
languageName: node
linkType: hard
"canvas@npm:^2.6.1":
version: 2.10.0
resolution: "canvas@npm:2.10.0"
version: 2.10.1
resolution: "canvas@npm:2.10.1"
dependencies:
"@mapbox/node-pre-gyp": ^1.0.0
nan: ^2.15.0
node-gyp: latest
simple-get: ^3.0.3
checksum: bed0f61d51a6f5d721742776e2dd1203ad6ca2475e1177cfb7a6f9ed1d313372ab8b4aa5a6780c85d35e533e2400cb5f291be1a1d35a84fc71d6372cd4cdbc61
checksum: 29b162a59df8c63e5591cae62711ab3cc3b0a078f260b39b4594b51c5bcb9baa597eff40b52f25bd788aceb81756a41097c2dd0c01cbc17ae4a15e873da44cdf
languageName: node
linkType: hard
@@ -19432,13 +19449,20 @@ __metadata:
languageName: node
linkType: hard
"core-js@npm:^3.4.1, core-js@npm:^3.6.5":
"core-js@npm:^3.4.1":
version: 3.25.0
resolution: "core-js@npm:3.25.0"
checksum: 5a72740bf5babaf2e6203da4c0e831a0b97c3fe6f21b4c1aa8601d3a927660a22128dd8f0e86ce84778c4e5372ab0fc03c7944afa7e215c7fb13b2c79d5d5504
languageName: node
linkType: hard
"core-js@npm:^3.6.5":
version: 3.25.1
resolution: "core-js@npm:3.25.1"
checksum: bfacb078e790913841e2d5008b9b6705ae56ed23f83c7f0ae08d330d874561012611089d53b71520105234ed0abba36f28d91b391514a16541a8c8d98c464239
languageName: node
linkType: hard
"core-util-is@npm:1.0.2, core-util-is@npm:~1.0.0":
version: 1.0.2
resolution: "core-util-is@npm:1.0.2"
@@ -21974,8 +21998,8 @@ __metadata:
linkType: hard
"eslint-plugin-jest@npm:^27.0.0":
version: 27.0.1
resolution: "eslint-plugin-jest@npm:27.0.1"
version: 27.0.2
resolution: "eslint-plugin-jest@npm:27.0.2"
dependencies:
"@typescript-eslint/utils": ^5.10.0
peerDependencies:
@@ -21986,7 +22010,7 @@ __metadata:
optional: true
jest:
optional: true
checksum: 269d4dc46bb925eb4c19106fd9e03775a863f53e05716628cc47777abc15887775ee47d73c4f8bdd98bb26b7462d8d8f654610bb2a367f8c97881204a2c3f42e
checksum: 0545568a1ddd14138f08015f5f1a99e08c0d5ef5d2f14a8e66c28c1b8296a541c1075e9660f637fcb71be01f2a709991937878fc96abc760c00efc768f4598c8
languageName: node
linkType: hard
@@ -22432,16 +22456,17 @@ __metadata:
"@material-ui/lab": 4.0.0-alpha.57
"@octokit/rest": ^19.0.3
"@rjsf/core": ^3.2.1
"@roadiehq/backstage-plugin-buildkite": ^2.0.0
"@roadiehq/backstage-plugin-github-insights": ^2.0.0
"@roadiehq/backstage-plugin-github-pull-requests": ^2.0.0
"@roadiehq/backstage-plugin-travis-ci": ^2.0.0
"@roadiehq/backstage-plugin-buildkite": ^2.0.8
"@roadiehq/backstage-plugin-github-insights": ^2.0.5
"@roadiehq/backstage-plugin-github-pull-requests": ^2.2.7
"@roadiehq/backstage-plugin-travis-ci": ^2.0.5
"@testing-library/cypress": ^8.0.2
"@testing-library/jest-dom": ^5.10.1
"@testing-library/react": ^12.1.3
"@testing-library/user-event": ^14.0.0
"@types/jquery": ^3.3.34
"@types/node": ^16.11.26
"@types/react": "*"
"@types/react-dom": "*"
"@types/zen-observable": ^0.8.0
cross-env: ^7.0.0
@@ -23310,6 +23335,16 @@ __metadata:
languageName: node
linkType: hard
"follow-redirects@npm:^1.14.9":
version: 1.15.1
resolution: "follow-redirects@npm:1.15.1"
peerDependenciesMeta:
debug:
optional: true
checksum: 6aa4e3e3cdfa3b9314801a1cd192ba756a53479d9d8cca65bf4db3a3e8834e62139245cd2f9566147c8dfe2efff1700d3e6aefd103de4004a7b99985e71dd533
languageName: node
linkType: hard
"for-in@npm:^1.0.2":
version: 1.0.2
resolution: "for-in@npm:1.0.2"
@@ -32321,6 +32356,17 @@ __metadata:
languageName: node
linkType: hard
"passport-auth0@npm:^1.4.3":
version: 1.4.3
resolution: "passport-auth0@npm:1.4.3"
dependencies:
axios: ^0.27.2
passport-oauth: ^1.0.0
passport-oauth2: ^1.6.0
checksum: 7658f58fd24831c67c783a9b0df3946bae9e6e42f8572747f17e77a2129d58ba21917a7531a4a328119b99a2a979ee857a1107c8e22e63e55739cdc1a8d1ccbc
languageName: node
linkType: hard
"passport-bitbucket-oauth2@npm:^0.1.2":
version: 0.1.2
resolution: "passport-bitbucket-oauth2@npm:0.1.2"
@@ -32379,7 +32425,7 @@ __metadata:
languageName: node
linkType: hard
"passport-oauth2@npm:1.6.1, passport-oauth2@npm:1.x.x, passport-oauth2@npm:^1.1.2, passport-oauth2@npm:^1.4.0, passport-oauth2@npm:^1.6.1":
"passport-oauth2@npm:1.6.1, passport-oauth2@npm:1.x.x, passport-oauth2@npm:^1.1.2, passport-oauth2@npm:^1.4.0, passport-oauth2@npm:^1.6.0, passport-oauth2@npm:^1.6.1":
version: 1.6.1
resolution: "passport-oauth2@npm:1.6.1"
dependencies:
@@ -32392,7 +32438,7 @@ __metadata:
languageName: node
linkType: hard
"passport-oauth@npm:1.0.0":
"passport-oauth@npm:1.0.0, passport-oauth@npm:^1.0.0":
version: 1.0.0
resolution: "passport-oauth@npm:1.0.0"
dependencies:
@@ -33915,8 +33961,8 @@ __metadata:
linkType: hard
"puppeteer@npm:^17.0.0":
version: 17.1.1
resolution: "puppeteer@npm:17.1.1"
version: 17.1.3
resolution: "puppeteer@npm:17.1.3"
dependencies:
cross-fetch: 3.1.5
debug: 4.3.4
@@ -33929,7 +33975,7 @@ __metadata:
tar-fs: 2.1.1
unbzip2-stream: 1.4.3
ws: 8.8.1
checksum: 064537270ea5745186c87888252395bb0d06d3ae73b02a617856329501d21e2ce5d05c1ee307dc2e851f7d45a2185b9cddd44ec54106ffcba51e7a622bb6faeb
checksum: b4518956c661df4c37690d46b9c6744d42d59d01fe3764938b4b3af8de4c94ef11a9dfa6bc261798f5e5490c6dce4d4f555f05d42c735249683da3017faf4585
languageName: node
linkType: hard
@@ -34552,7 +34598,7 @@ __metadata:
languageName: node
linkType: hard
"react-router-stable@npm:react-router@^6.3.0, react-router@npm:6.3.0, react-router@npm:^6.0.0-beta.0, react-router@npm:^6.3.0":
"react-router-stable@npm:react-router@^6.3.0, react-router@npm:6.3.0, react-router@npm:^6.3.0":
version: 6.3.0
resolution: "react-router@npm:6.3.0"
dependencies:
@@ -35986,6 +36032,7 @@ __metadata:
concurrently: ^7.0.0
cross-env: ^7.0.0
e2e-test: "workspace:*"
eslint: ^8.6.0
eslint-plugin-notice: ^0.9.10
fs-extra: 10.1.0
husky: ^8.0.0