Merge branch 'master' into mob/scaffolder-frontend
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
pass registered logger to requestLoggingHandler
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/integration': minor
|
||||
---
|
||||
|
||||
Make `ScmIntegration.resolveUrl` mandatory.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Hide the kind of the owner if it's the default kind for the `ownedBy`
|
||||
relationship (group).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Remove the "Move repository" menu entry from the catalog page, as it's just a placeholder.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-api': patch
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
Export `createExternalRouteRef`, as well as give it an `id` for easier debugging, and fix parameter requirements when used with `useRouteRef`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-user-settings': patch
|
||||
---
|
||||
|
||||
Use routed tabs to link to every settings page.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Tweak error message in lockfile parsing to include more information.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Fixed parsing of OIDC key timestamps when using SQLite.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Migrate about card to new composability API, exporting the entity cards as `EntityAboutCard`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Fixed the `prepare` step for when using local templates that were added to the catalog using the `file:` target configuration.
|
||||
No more `EPERM: operation not permitted` error messages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
Export Select component
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
Add Breadcrumbs component
|
||||
@@ -119,6 +119,7 @@ Kumar
|
||||
learnings
|
||||
lerna
|
||||
Lerna
|
||||
lockfile
|
||||
Luxon
|
||||
magiclink
|
||||
mailto
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -205,7 +205,8 @@ described below.
|
||||
|
||||
In addition to these, you may add any number of other fields directly under
|
||||
`metadata`, but be aware that general plugins and tools may not be able to
|
||||
understand their semantics.
|
||||
understand their semantics. See [Extending the model](extending-the-model.md)
|
||||
for more information.
|
||||
|
||||
### `name` [required]
|
||||
|
||||
@@ -214,8 +215,8 @@ entity, and for machines and other components to reference the entity (e.g. in
|
||||
URLs or from other entity specification files).
|
||||
|
||||
Names must be unique per kind, within a given namespace (if specified), at any
|
||||
point in time. Names may be reused at a later time, after an entity is deleted
|
||||
from the registry.
|
||||
point in time. This uniqueness constraint is case insensitive. Names may be
|
||||
reused at a later time, after an entity is deleted from the registry.
|
||||
|
||||
Names are required to follow a certain format. Entities that do not follow those
|
||||
rules will not be accepted for registration in the catalog. The ruleset is
|
||||
@@ -226,19 +227,7 @@ follows.
|
||||
- Must consist of sequences of `[a-z0-9A-Z]` possibly separated by one of
|
||||
`[-_.]`
|
||||
|
||||
Example: `visits-tracking-service`, `CircleciBuildsDump_avro_gcs`
|
||||
|
||||
In addition to this, names are passed through a normalization function and then
|
||||
compared to the same normalized form of other entity names and made sure to not
|
||||
collide. This rule of uniqueness exists to avoid situations where e.g. both
|
||||
`my-component` and `MyComponent` are registered side by side, which leads to
|
||||
confusion and risk. The normalization function is also configurable, but the
|
||||
default behavior is as follows.
|
||||
|
||||
- Strip out all characters outside of the set `[a-zA-Z0-9]`
|
||||
- Convert to lowercase
|
||||
|
||||
Example: `CircleciBuildsDs_avro_gcs` -> `circlecibuildsdsavrogcs`
|
||||
Example: `visits-tracking-service`, `CircleciBuildsDumpV2_avro_gcs`
|
||||
|
||||
### `namespace` [optional]
|
||||
|
||||
@@ -248,7 +237,8 @@ the same format restrictions as `name` above.
|
||||
This field is optional, and currently has no special semantics apart from
|
||||
bounding the name uniqueness constraint if specified. It is reserved for future
|
||||
use and may get broader semantic implication later. For now, it is recommended
|
||||
to not specify a namespace unless you have specific need to do so.
|
||||
to not specify a namespace unless you have specific need to do so. This means
|
||||
the entity belongs to the `"default"` namespace.
|
||||
|
||||
Namespaces may also be part of the catalog, and are `v1` / `Namespace` entities,
|
||||
i.e. not Backstage specific but the same as in Kubernetes.
|
||||
@@ -278,7 +268,6 @@ most 253 characters in total. The name part must be sequences of `[a-zA-Z0-9]`
|
||||
separated by any of `[-_.]`, at most 63 characters in total.
|
||||
|
||||
The `backstage.io/` prefix is reserved for use by Backstage core components.
|
||||
Some keys such as `system` also have predefined semantics.
|
||||
|
||||
Values are strings that follow the same restrictions as `name` above.
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ The Backstage Glossary lists all the terms, abbreviations, and phrases used in
|
||||
Backstage, together with their explanations. We encourage you to use the
|
||||
terminology below for clarity and consistency when discussing Backstage.
|
||||
|
||||
### Authentication Glossary
|
||||
|
||||
This [page](./auth/glossary.md) directs to the terms and phrases related to
|
||||
authentication and identity section of Backstage.
|
||||
|
||||
### Backstage User Profiles
|
||||
|
||||
There are three main user profiles for Backstage: the integrator, the
|
||||
|
||||
@@ -48,10 +48,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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<SearchResponse> {
|
||||
throw new Error('AzureUrlReader does not implement search');
|
||||
async search(url: string, options?: SearchOptions): Promise<SearchResponse> {
|
||||
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)}}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Buffer> {
|
||||
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<SearchResponse> {
|
||||
throw new Error('BitbucketUrlReader does not implement search');
|
||||
async search(url: string, options?: SearchOptions): Promise<SearchResponse> {
|
||||
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}`;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<Buffer> {
|
||||
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 },
|
||||
);
|
||||
|
||||
|
||||
@@ -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,24 +38,44 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
|
||||
});
|
||||
|
||||
const gitlabProcessor = new GitlabUrlReader(
|
||||
{
|
||||
host: 'gitlab.com',
|
||||
apiBaseUrl: 'https://gitlab.com/api/v4',
|
||||
baseUrl: 'https://gitlab.com',
|
||||
},
|
||||
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',
|
||||
baseUrl: 'https://gitlab.mycompany.com',
|
||||
},
|
||||
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);
|
||||
|
||||
@@ -156,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'),
|
||||
);
|
||||
@@ -382,4 +397,80 @@ describe('GitlabUrlReader', () => {
|
||||
await expect(fnGithub).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,49 +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;
|
||||
}
|
||||
private readonly integration: GitLabIntegration,
|
||||
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
|
||||
) {}
|
||||
|
||||
async read(url: string): Promise<Buffer> {
|
||||
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}`);
|
||||
}
|
||||
@@ -86,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}`;
|
||||
@@ -106,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}`;
|
||||
@@ -128,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}`;
|
||||
@@ -141,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,
|
||||
@@ -149,12 +153,33 @@ export class GitlabUrlReader implements UrlReader {
|
||||
});
|
||||
}
|
||||
|
||||
async search(): Promise<SearchResponse> {
|
||||
throw new Error('GitlabUrlReader does not implement search');
|
||||
async search(url: string, options?: SearchOptions): Promise<SearchResponse> {
|
||||
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)}}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ metadata:
|
||||
- url: https://example.com/web
|
||||
title: Website
|
||||
icon: web
|
||||
- url: https://example.com/alert
|
||||
title: Alerts
|
||||
icon: alert
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: experimental
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AnyApiFactory>;
|
||||
icons: SystemIcons;
|
||||
icons: IconComponentMap;
|
||||
plugins: BackstagePlugin<any, any>[];
|
||||
components: AppComponents;
|
||||
themes: AppTheme[];
|
||||
@@ -144,7 +144,7 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
private configApi?: ConfigApi;
|
||||
|
||||
private readonly apis: Iterable<AnyApiFactory>;
|
||||
private readonly icons: SystemIcons;
|
||||
private readonly icons: IconComponentMap;
|
||||
private readonly plugins: BackstagePlugin<any, any>[];
|
||||
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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SystemIcons>;
|
||||
icons?: IconComponentMap;
|
||||
|
||||
/**
|
||||
* A list of all plugins to include in the app.
|
||||
@@ -169,9 +169,9 @@ export type BackstageApp = {
|
||||
getPlugins(): BackstagePlugin<any, any>[];
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<SvgIconProps>;
|
||||
export type SystemIconKey = 'user' | 'group';
|
||||
export type SystemIcons = { [key in SystemIconKey]: IconComponent };
|
||||
export type IconKey = SystemIconKey | string;
|
||||
export type IconComponentMap = { [key in IconKey]: IconComponent };
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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 { Box, List, ListItem, Popover, Typography } from '@material-ui/core';
|
||||
import ExpandLessIcon from '@material-ui/icons/ExpandLess';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import React, { Fragment } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Breadcrumbs } from '.';
|
||||
import { Header, Page } from '..';
|
||||
import { Link } from '../../components/Link';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Breadcrumbs',
|
||||
component: Breadcrumbs,
|
||||
};
|
||||
|
||||
export const InHeader = () => (
|
||||
<MemoryRouter>
|
||||
<h2>Standard breadcrumbs</h2>
|
||||
<p>
|
||||
Underlined pages are links. This should show a hierarchical relationship.
|
||||
</p>
|
||||
|
||||
<Page themeId="other">
|
||||
<Header title="Current Page" type="General Page" typeLink="/" />
|
||||
</Page>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
export const OutsideOfHeader = () => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLAnchorElement | null>(
|
||||
null,
|
||||
);
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<p>
|
||||
It might be the case that you want to keep your breadcrumbs outside of
|
||||
the header. In that case, they should be positioned above the title of
|
||||
the page.
|
||||
</p>
|
||||
|
||||
<h2>Standard breadcrumbs</h2>
|
||||
<p>
|
||||
Underlined pages are links. This should show a hierarchical
|
||||
relationship.
|
||||
</p>
|
||||
|
||||
<Breadcrumbs color="primaryText" />
|
||||
|
||||
<Breadcrumbs color="primaryText">
|
||||
<Link to="/">General Page</Link>
|
||||
<Link to="/">Second Page</Link>
|
||||
<Typography>Current page</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<h2>Hidden breadcrumbs</h2>
|
||||
<p>
|
||||
Use this when you have more than three breadcrumbs. When user clicks on
|
||||
ellipses, expand the breadcrumbs out.
|
||||
</p>
|
||||
|
||||
<Breadcrumbs color="primaryText">
|
||||
<Link to="/">General Page</Link>
|
||||
<Link to="/">Second Page</Link>
|
||||
<Link to="/">Third Page</Link>
|
||||
<Link to="/">Fourth Page</Link>
|
||||
<Typography>Current page</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<h2>Layered breadcrumbs</h2>
|
||||
<p>
|
||||
Use this when you want to show alternative breadcrumbs on the same
|
||||
hierarchical level.
|
||||
</p>
|
||||
|
||||
<Fragment>
|
||||
<Breadcrumbs color="primaryText">
|
||||
<Link to="/">General Page</Link>
|
||||
<Link to="/" onClick={handleClick}>
|
||||
<Box display="flex" alignItems="center">
|
||||
<span>Second Page</span>
|
||||
{open ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</Box>
|
||||
</Link>
|
||||
<Typography>Current page</Typography>
|
||||
</Breadcrumbs>
|
||||
<Popover
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<List>
|
||||
<ListItem button style={{ textDecoration: 'underline' }}>
|
||||
Parallel second page
|
||||
</ListItem>
|
||||
<ListItem button style={{ textDecoration: 'underline' }}>
|
||||
Another parallel second page
|
||||
</ListItem>
|
||||
<ListItem button style={{ textDecoration: 'underline' }}>
|
||||
Yet another, parallel second page
|
||||
</ListItem>
|
||||
</List>
|
||||
</Popover>
|
||||
</Fragment>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Link } from '../..';
|
||||
import { Breadcrumbs } from './Breadcrumbs';
|
||||
|
||||
describe('<Breadcrumbs/>', () => {
|
||||
it('should render', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<Breadcrumbs>
|
||||
<Link to="/">General Page</Link>
|
||||
<Typography>Current Page</Typography>
|
||||
</Breadcrumbs>,
|
||||
);
|
||||
expect(rendered.getByLabelText('breadcrumb')).toBeVisible();
|
||||
expect(rendered.getByText('General Page')).toBeVisible();
|
||||
expect(rendered.getByText('Current Page')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render hidden breadcrumbs', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<Breadcrumbs>
|
||||
<Link to="/">General Page</Link>
|
||||
<Link to="/">Second Page</Link>
|
||||
<Link to="/">Third Page</Link>
|
||||
<Link to="/">Fourth Page</Link>
|
||||
<Typography>Current page</Typography>
|
||||
</Breadcrumbs>,
|
||||
);
|
||||
expect(rendered.getByText('...')).toBeVisible();
|
||||
expect(rendered.queryByText('Third Page')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText('Fourth Page')).not.toBeInTheDocument();
|
||||
fireEvent.click(rendered.getByText('...'));
|
||||
expect(rendered.getByText('Third Page')).toBeVisible();
|
||||
expect(rendered.getByText('Fourth Page')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 {
|
||||
Box,
|
||||
Breadcrumbs as MaterialBreadcrumbs,
|
||||
List,
|
||||
ListItem,
|
||||
Popover,
|
||||
Typography,
|
||||
withStyles,
|
||||
} from '@material-ui/core';
|
||||
import React, { ComponentProps, Fragment } from 'react';
|
||||
|
||||
type Props = ComponentProps<typeof MaterialBreadcrumbs>;
|
||||
|
||||
const ClickableText = withStyles({
|
||||
root: {
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})(Typography);
|
||||
|
||||
const StyledBox = withStyles({
|
||||
root: {
|
||||
textDecoration: 'underline',
|
||||
color: 'inherit',
|
||||
},
|
||||
})(Box);
|
||||
|
||||
export const Breadcrumbs = ({ children, ...props }: Props) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
const [firstPage, secondPage, ...expandablePages] = childrenArray;
|
||||
const currentPage = expandablePages.length
|
||||
? expandablePages.pop()
|
||||
: childrenArray[childrenArray.length - 1];
|
||||
const hasHiddenBreadcrumbs = childrenArray.length > 3;
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
return (
|
||||
<Fragment>
|
||||
<MaterialBreadcrumbs aria-label="breadcrumb" {...props}>
|
||||
{childrenArray.length > 1 && <StyledBox clone>{firstPage}</StyledBox>}
|
||||
{childrenArray.length > 2 && <StyledBox clone>{secondPage}</StyledBox>}
|
||||
{hasHiddenBreadcrumbs && (
|
||||
<ClickableText onClick={handleClick}>...</ClickableText>
|
||||
)}
|
||||
<Box style={{ fontStyle: 'italic' }}>{currentPage}</Box>
|
||||
</MaterialBreadcrumbs>
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<List>
|
||||
{expandablePages.map(pageLink => (
|
||||
<ListItem button>
|
||||
<StyledBox clone>{pageLink}</StyledBox>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Popover>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { Breadcrumbs } from './Breadcrumbs';
|
||||
@@ -16,15 +16,10 @@
|
||||
|
||||
import React, { ReactNode, CSSProperties, PropsWithChildren } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {
|
||||
Link,
|
||||
Typography,
|
||||
Tooltip,
|
||||
makeStyles,
|
||||
Breadcrumbs,
|
||||
} from '@material-ui/core';
|
||||
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
|
||||
import { Typography, Tooltip, makeStyles } from '@material-ui/core';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { Breadcrumbs } from '..';
|
||||
import { Link } from '../../components';
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
header: {
|
||||
@@ -136,15 +131,9 @@ const TypeFragment = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumbs
|
||||
aria-label="breadcrumb"
|
||||
separator={<ChevronRightIcon fontSize="small" />}
|
||||
className={classes.breadcrumb}
|
||||
>
|
||||
<Link href={typeLink} color="inherit">
|
||||
<Typography className={classes.breadcrumbType}> {type}</Typography>
|
||||
</Link>
|
||||
<Typography className={classes.breadcrumbTitle}>{pageTitle}</Typography>
|
||||
<Breadcrumbs className={classes.breadcrumb}>
|
||||
<Link to={typeLink}>{type}</Link>
|
||||
<Typography>{pageTitle}</Typography>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,3 +28,4 @@ export * from './Page';
|
||||
export * from './Sidebar';
|
||||
export * from './SignInPage';
|
||||
export * from './TabbedCard';
|
||||
export * from './Breadcrumbs';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { BitbucketIntegration } from './BitbucketIntegration';
|
||||
export {
|
||||
readBitbucketIntegrationConfig,
|
||||
readBitbucketIntegrationConfigs,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,4 @@ export {
|
||||
export type { GitHubIntegrationConfig } from './config';
|
||||
export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core';
|
||||
export { GithubCredentialsProvider } from './GithubCredentialsProvider';
|
||||
export { GitHubIntegration } from './GitHubIntegration';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,3 +20,4 @@ export {
|
||||
} from './config';
|
||||
export type { GitLabIntegrationConfig } from './config';
|
||||
export { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core';
|
||||
export { GitLabIntegration } from './GitLabIntegration';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { ScmIntegration, ScmIntegrationsGroup } from './types';
|
||||
|
||||
/** Checks whether the given argument is a valid URL hostname */
|
||||
@@ -51,3 +52,43 @@ export function basicIntegrations<T extends ScmIntegration>(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 `<repo root url>/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
|
||||
* `<repo root url>/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<GitLabIntegration>;
|
||||
|
||||
/**
|
||||
* 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 `<repo root url>/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
|
||||
* `<repo root url>/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
|
||||
|
||||
@@ -39,7 +39,7 @@ type Options = {
|
||||
const parseDate = (date: string | Date) => {
|
||||
const parsedDate =
|
||||
typeof date === 'string'
|
||||
? DateTime.fromSQL(date, { locale: 'UTC' })
|
||||
? DateTime.fromSQL(date, { zone: 'UTC' })
|
||||
: DateTime.fromJSDate(date);
|
||||
|
||||
if (!parsedDate.isValid) {
|
||||
|
||||
@@ -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('<AboutCard /> GitHub', () => {
|
||||
@@ -36,7 +37,11 @@ describe('<AboutCard /> GitHub', () => {
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
const { getByText } = render(<AboutCard entity={entity} />);
|
||||
const { getByText } = render(
|
||||
<EntityProvider entity={entity}>
|
||||
<AboutCard />
|
||||
</EntityProvider>,
|
||||
);
|
||||
expect(getByText('service')).toBeInTheDocument();
|
||||
expect(getByText('View Source').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
@@ -67,7 +72,11 @@ describe('<AboutCard /> GitLab', () => {
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
const { getByText } = render(<AboutCard entity={entity} />);
|
||||
const { getByText } = render(
|
||||
<EntityProvider entity={entity}>
|
||||
<AboutCard />
|
||||
</EntityProvider>,
|
||||
);
|
||||
expect(getByText('service')).toBeInTheDocument();
|
||||
expect(getByText('View Source').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
@@ -98,7 +107,11 @@ describe('<AboutCard /> BitBucket', () => {
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
const { getByText } = render(<AboutCard entity={entity} />);
|
||||
const { getByText } = render(
|
||||
<EntityProvider entity={entity}>
|
||||
<AboutCard />
|
||||
</EntityProvider>,
|
||||
);
|
||||
expect(getByText('service')).toBeInTheDocument();
|
||||
expect(getByText('View Source').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -66,7 +66,7 @@ export const AboutContent = ({ entity }: Props) => {
|
||||
</Typography>
|
||||
</AboutField>
|
||||
<AboutField label="Owner" gridSizes={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<EntityRefLinks entityRefs={ownedByRelations} />
|
||||
<EntityRefLinks entityRefs={ownedByRelations} defaultKind="group" />
|
||||
</AboutField>
|
||||
{isSystem && (
|
||||
<AboutField
|
||||
|
||||
@@ -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) => {
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Unregister entity</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<SwapHoriz fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Move repository</Typography>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Popover>
|
||||
</>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { IconComponent, InfoCard, useApp } from '@backstage/core';
|
||||
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';
|
||||
@@ -33,9 +33,8 @@ export const EntityLinksCard = ({ cols = undefined }: Props) => {
|
||||
const { entity } = useEntity();
|
||||
const app = useApp();
|
||||
|
||||
// TODO: Refactor App.icons & App.getSystemIcon to support custom icons
|
||||
const iconResolver = (key: string | undefined): IconComponent => {
|
||||
return app.getSystemIcon(key as any) ?? LanguageIcon;
|
||||
const iconResolver = (key: IconKey | undefined): IconComponent => {
|
||||
return app.getSystemIcon(key ?? '') ?? LanguageIcon;
|
||||
};
|
||||
|
||||
const links = entity?.metadata?.links;
|
||||
|
||||
@@ -24,5 +24,6 @@ export {
|
||||
catalogPlugin as plugin,
|
||||
CatalogIndexPage,
|
||||
CatalogEntityPage,
|
||||
EntityAboutCard,
|
||||
EntityLinksCard,
|
||||
} from './plugin';
|
||||
|
||||
@@ -71,6 +71,14 @@ export const CatalogEntityPage = catalogPlugin.provide(
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityAboutCard = catalogPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () => import('./components/AboutCard').then(m => m.AboutCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityLinksCard = catalogPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('File preparer', () => {
|
||||
const preparer = new FilePreparer();
|
||||
const root = os.platform() === 'win32' ? 'C:\\' : '/';
|
||||
const workspacePath = path.join(root, 'tmp');
|
||||
const checkoutPath = path.resolve(workspacePath, 'checkout');
|
||||
const targetPath = path.resolve(workspacePath, 'template');
|
||||
|
||||
await preparer.prepare({
|
||||
url: `file:///${root}path/to/template`,
|
||||
@@ -37,12 +37,12 @@ describe('File preparer', () => {
|
||||
});
|
||||
expect(fs.copy).toHaveBeenCalledWith(
|
||||
path.join(root, 'path', 'to', 'template'),
|
||||
checkoutPath,
|
||||
targetPath,
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
expect(fs.ensureDir).toHaveBeenCalledWith(checkoutPath);
|
||||
expect(fs.ensureDir).toHaveBeenCalledWith(targetPath);
|
||||
|
||||
await expect(
|
||||
preparer.prepare({
|
||||
|
||||
@@ -25,12 +25,12 @@ export class FilePreparer implements PreparerBase {
|
||||
throw new InputError(`Wrong location protocol, should be 'file', ${url}`);
|
||||
}
|
||||
|
||||
const checkoutDir = path.join(workspacePath, 'checkout');
|
||||
await fs.ensureDir(checkoutDir);
|
||||
const targetDir = path.join(workspacePath, 'template');
|
||||
await fs.ensureDir(targetDir);
|
||||
|
||||
const templatePath = fileURLToPath(url);
|
||||
|
||||
await fs.copy(templatePath, checkoutDir, {
|
||||
await fs.copy(templatePath, targetDir, {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { Config } from '@backstage/config';
|
||||
import Docker from 'dockerode';
|
||||
import express from 'express';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { resolve as resolvePath, dirname } from 'path';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
@@ -171,7 +171,7 @@ export async function createRouter(
|
||||
const preparer = new FilePreparer();
|
||||
|
||||
const path = resolvePath(
|
||||
templateEntityLocation,
|
||||
dirname(templateEntityLocation),
|
||||
template.spec.path || '.',
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import { Button, useRouteRef } from '@backstage/core';
|
||||
import { BackstageTheme, pageTheme } from '@backstage/theme';
|
||||
import {
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
Chip,
|
||||
makeStyles,
|
||||
Typography,
|
||||
@@ -33,18 +35,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 = {
|
||||
@@ -75,19 +70,19 @@ export const TemplateCard = ({
|
||||
<Typography variant="subtitle2">{type}</Typography>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
</div>
|
||||
<div className={classes.content}>
|
||||
<CardContent>
|
||||
{tags?.map(tag => (
|
||||
<Chip label={tag} key={tag} />
|
||||
))}
|
||||
<Typography variant="body2" paragraph className={classes.description}>
|
||||
{description}
|
||||
</Typography>
|
||||
<div className={classes.footer}>
|
||||
<Button color="primary" to={templateLink({ templateName: name })}>
|
||||
Choose
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button color="primary" to={templateLink({ templateName: name })}>
|
||||
Choose
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('TemplatePage', () => {
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
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'));
|
||||
});
|
||||
@@ -115,7 +115,7 @@ describe('TemplatePage', () => {
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
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(() => {
|
||||
@@ -140,7 +140,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();
|
||||
});
|
||||
|
||||
@@ -117,10 +117,10 @@ export const TemplatePage = () => {
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
pageTitleOverride="Create a new component"
|
||||
pageTitleOverride="Create a New Component"
|
||||
title={
|
||||
<>
|
||||
Create a new component <Lifecycle alpha shorthand />
|
||||
Create a New Component <Lifecycle alpha shorthand />
|
||||
</>
|
||||
}
|
||||
subtitle="Create new software components using standard templates"
|
||||
|
||||
@@ -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<number>(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 = [
|
||||
<General />,
|
||||
<AuthProviders providerSettings={providerSettings} />,
|
||||
<FeatureFlags />,
|
||||
];
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header title="Settings" />
|
||||
<HeaderTabs tabs={tabs} onChange={onTabChange} />
|
||||
<Content>{content[activeTab]}</Content>
|
||||
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="general" title="General">
|
||||
<General />
|
||||
</TabbedLayout.Route>
|
||||
<TabbedLayout.Route
|
||||
path="auth-providers"
|
||||
title="Authentication Providers"
|
||||
>
|
||||
<AuthProviders providerSettings={providerSettings} />
|
||||
</TabbedLayout.Route>
|
||||
<TabbedLayout.Route path="feature-flags" title="Feature Flags">
|
||||
<FeatureFlags />
|
||||
</TabbedLayout.Route>
|
||||
</TabbedLayout>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4623,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"
|
||||
@@ -4698,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==
|
||||
@@ -4713,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"
|
||||
@@ -4763,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"
|
||||
@@ -4789,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"
|
||||
@@ -4807,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"
|
||||
|
||||
@@ -4855,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"
|
||||
@@ -4871,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"
|
||||
@@ -4923,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"
|
||||
@@ -4937,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"
|
||||
@@ -5113,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"
|
||||
@@ -5174,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"
|
||||
|
||||
Reference in New Issue
Block a user