Merge branch 'backstage:master' into fix

This commit is contained in:
SrikarAnanthula
2021-08-24 14:20:36 -07:00
committed by GitHub
47 changed files with 1726 additions and 85 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
GitHub discovery processor adds support for discovering the default GitHub branch
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-module-pg': patch
---
Sanitize special characters before building search query for postgres
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-git-release-manager': patch
---
Expose internal constants, helpers and components to make it easier for users to build custom features for GRM.
+14
View File
@@ -0,0 +1,14 @@
---
'@backstage/plugin-catalog-react': patch
---
Memoize the context value in `EntityListProvider`.
This removes quite a few unnecessary rerenders of the inner components.
When running the full `CatalogPage` test:
- Before: 98 table render calls total, 16 seconds runtime
- After: 57 table render calls total, 14 seconds runtime
This doesn't account for all of the slowness, but does give a minor difference in perceived speed in the browser too.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Fixed a bug where OAuth state parameters would be serialized as the string `'undefined'`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Added GitLabDiscoveryProcessor, which allows catalog discovery from a GitLab instance
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes-backend': patch
---
Fixes bug reading ExternalId from k8s backend config
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': minor
---
Updated the default create-app `EntityPage` to include orphan and processing error alerts for all entity types. Previously these were only shown for entities with the `Component` kind. The `EntityPage` in Backstage applications should be updated following [d027681](https://github.com/backstage/backstage/pull/6899/commits/d0276817123ba131c9211de30d229839f13d7775). This also adds the `EntityLinkCard` for API entities.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Fix search page to respond to searches made from sidebar search
+2
View File
@@ -142,6 +142,7 @@ maintainership
makefile
md
memcache
memoize
memoized
microservice
microservices
@@ -215,6 +216,7 @@ repo
Repo
repos
rerender
rerenders
Reusability
reusability
roadmaps
+36
View File
@@ -0,0 +1,36 @@
---
id: discovery
title: GitLab Discovery
sidebar_label: Discovery
# prettier-ignore
description: Automatically discovering catalog entities from repositories in GitLab
---
The GitLab integration has a special discovery processor for discovering catalog
entities from GitLab. The processor will crawl the GitLab instance and register
entities matching the configured path. This can be useful as an alternative to
static locations or manually adding things to the catalog.
To use the discovery processor, you'll need a GitLab integration
[set up](locations.md) with a `token`. Then you can add a location target to the
catalog configuration:
```yaml
catalog:
locations:
- type: gitlab-discovery
target: https://gitlab.com/group/subgroup/blob/main/catalog-info.yaml
```
Note the `gitlab-discovery` type, as this is not a regular `url` processor.
The target is composed of three parts:
- The base URL, `https://gitlab.com` in this case
- The group path, `group/subgroup` in this case. This is optional: If you omit
this path the processor will scan the entire GitLab instance instead.
- The path within each repository to find the catalog YAML file. This will
usually be `/blob/main/catalog-info.yaml`, `/blob/master/catalog-info.yaml` or
a similar variation for catalog files stored in the root directory of each
repository. If you want to use the repository's default branch use the `*`
wildcard, e.g.: `/blob/*/catalog-info.yaml`
@@ -211,20 +211,8 @@ const cicdCard = (
</EntitySwitch>
);
const errorsContent = (
<EntitySwitch>
<EntitySwitch.Case if={isRollbarAvailable}>
<EntityRollbarContent />
</EntitySwitch.Case>
<EntitySwitch.Case>
<EntitySentryContent />
</EntitySwitch.Case>
</EntitySwitch>
);
const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
const entityWarningContent = (
<>
<EntitySwitch>
<EntitySwitch.Case if={isOrphan}>
<Grid item xs={12}>
@@ -240,7 +228,24 @@ const overviewContent = (
</Grid>
</EntitySwitch.Case>
</EntitySwitch>
</>
);
const errorsContent = (
<EntitySwitch>
<EntitySwitch.Case if={isRollbarAvailable}>
<EntityRollbarContent />
</EntitySwitch.Case>
<EntitySwitch.Case>
<EntitySentryContent />
</EntitySwitch.Case>
</EntitySwitch>
);
const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={8} xs={12}>
<EntityAboutCard variant="gridItem" />
</Grid>
@@ -448,6 +453,7 @@ const apiPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12}>
<EntityAboutCard />
</Grid>
@@ -478,6 +484,7 @@ const userPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityUserProfileCard variant="gridItem" />
</Grid>
@@ -493,6 +500,7 @@ const groupPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityGroupProfileCard variant="gridItem" />
</Grid>
@@ -511,6 +519,7 @@ const systemPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
</Grid>
@@ -535,6 +544,7 @@ const domainPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
</Grid>
@@ -82,8 +82,8 @@ const cicdContent = (
</EntitySwitch>
);
const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
const entityWarningContent = (
<>
<EntitySwitch>
<EntitySwitch.Case if={isOrphan}>
<Grid item xs={12}>
@@ -99,7 +99,12 @@ const overviewContent = (
</Grid>
</EntitySwitch.Case>
</EntitySwitch>
</>
);
const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
</Grid>
@@ -214,9 +219,13 @@ const apiPage = (
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard />
</Grid>
<Grid item md={4} xs={12}>
<EntityLinksCard />
</Grid>
<Grid container item md={12}>
<Grid item md={6}>
<EntityProvidingComponentsCard />
@@ -242,6 +251,7 @@ const userPage = (
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityUserProfileCard variant="gridItem" />
</Grid>
@@ -257,6 +267,7 @@ const groupPage = (
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityGroupProfileCard variant="gridItem" />
</Grid>
@@ -275,6 +286,7 @@ const systemPage = (
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
</Grid>
@@ -299,6 +311,7 @@ const domainPage = (
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
</Grid>
+1
View File
@@ -50,6 +50,7 @@
"jose": "^1.27.1",
"jwt-decode": "^3.1.0",
"knex": "^0.95.1",
"lodash": "^4.17.21",
"luxon": "^2.0.2",
"minimatch": "^3.0.3",
"morgan": "^1.10.0",
@@ -15,9 +15,39 @@
*/
import express from 'express';
import { verifyNonce, encodeState } from './helpers';
import { verifyNonce, encodeState, readState } from './helpers';
describe('OAuthProvider Utils', () => {
describe('encodeState', () => {
it('should serialized values', () => {
const state = {
nonce: '123',
env: 'development',
origin: 'https://example.com',
};
const encoded = encodeState(state);
expect(encoded).toBe(
Buffer.from(
'nonce=123&env=development&origin=https%3A%2F%2Fexample.com',
).toString('hex'),
);
expect(readState(encoded)).toEqual(state);
});
it('should not include undefined values', () => {
const state = { nonce: '123', env: 'development', origin: undefined };
const encoded = encodeState(state);
expect(encoded).toBe(
Buffer.from('nonce=123&env=development').toString('hex'),
);
expect(readState(encoded)).toEqual(state);
});
});
describe('verifyNonce', () => {
it('should throw error if cookie nonce missing', () => {
const state = { nonce: 'NONCE', env: 'development' };
@@ -16,6 +16,7 @@
import express from 'express';
import { OAuthState } from './types';
import pickBy from 'lodash/pickBy';
export const readState = (stateString: string): OAuthState => {
const state = Object.fromEntries(
@@ -34,7 +35,9 @@ export const readState = (stateString: string): OAuthState => {
};
export const encodeState = (state: OAuthState): string => {
const stateString = new URLSearchParams(state).toString();
const stateString = new URLSearchParams(
pickBy(state, value => value !== undefined),
).toString();
return Buffer.from(stateString, 'utf-8').toString('hex');
};
+21
View File
@@ -813,6 +813,27 @@ export class GithubOrgReaderProcessor implements CatalogProcessor {
): Promise<boolean>;
}
// Warning: (ae-missing-release-tag) "GitLabDiscoveryProcessor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export class GitLabDiscoveryProcessor implements CatalogProcessor {
// (undocumented)
static fromConfig(
config: Config,
options: {
logger: Logger_2;
},
): GitLabDiscoveryProcessor;
// (undocumented)
readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
): Promise<boolean>;
// (undocumented)
updateLastActivity(): Promise<string | undefined>;
}
// Warning: (ae-missing-release-tag) "HigherOrderOperation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -0,0 +1,368 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { LocationSpec } from '@backstage/catalog-model';
import { GitLabDiscoveryProcessor, parseUrl } from './GitLabDiscoveryProcessor';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { GitLabProject } from './gitlab';
const server = setupServer();
const PROJECTS_URL = 'https://gitlab.fake/api/v4/projects';
const GROUP_PROJECTS_URL =
'https://gitlab.fake/api/v4/groups/group%2Fsubgroup/projects';
const PROJECT_LOCATION: LocationSpec = {
type: 'gitlab-discovery',
target: 'https://gitlab.fake/blob/*/catalog-info.yaml',
};
const PROJECT_LOCATION_MASTER_BRANCH: LocationSpec = {
type: 'gitlab-discovery',
target: 'https://gitlab.fake/blob/master/catalog-info.yaml',
};
const GROUP_LOCATION: LocationSpec = {
type: 'gitlab-discovery',
target: 'https://gitlab.fake/group/subgroup/blob/*/catalog-info.yaml',
};
function setupFakeServer(
url: string,
callback: (request: { page: number; include_subgroups: boolean }) => {
data: GitLabProject[];
nextPage?: number;
},
) {
server.use(
rest.get(url, (req, res, ctx) => {
if (req.headers.get('private-token') !== 'test-token') {
return res(ctx.status(401), ctx.json({}));
}
const page = req.url.searchParams.get('page');
const include_subgroups = req.url.searchParams.get('include_subgroups');
const response = callback({
page: parseInt(page!, 10),
include_subgroups: include_subgroups === 'true',
});
// Filter the fake results based on the `last_activity_after` parameter
const last_activity_after = req.url.searchParams.get(
'last_activity_after',
);
const filteredData = response.data.filter(
v =>
!last_activity_after ||
Date.parse(v.last_activity_at) >= Date.parse(last_activity_after),
);
return res(
ctx.set('x-next-page', response.nextPage?.toString() ?? ''),
ctx.json(filteredData),
);
}),
);
}
function getConfig(): any {
return {
backend: {
cache: { store: 'memory' },
},
integrations: {
gitlab: [
{
host: 'gitlab.fake',
apiBaseUrl: 'https://gitlab.fake/api/v4',
token: 'test-token',
},
],
},
};
}
function getProcessor(config?: any): GitLabDiscoveryProcessor {
return GitLabDiscoveryProcessor.fromConfig(
new ConfigReader(config || getConfig()),
{
logger: getVoidLogger(),
},
);
}
describe('GitlabDiscoveryProcessor', () => {
beforeAll(() => {
server.listen();
jest.useFakeTimers('modern');
jest.setSystemTime(new Date('2001-01-01T12:34:56Z'));
});
afterEach(() => server.resetHandlers());
afterAll(() => {
server.close();
jest.useRealTimers();
});
describe('parseUrl', () => {
it('parses well formed URLs', () => {
expect(
parseUrl('https://gitlab.com/group/subgroup/blob/master/catalog.yaml'),
).toEqual({
group: 'group/subgroup',
host: 'gitlab.com',
branch: 'master',
catalogPath: 'catalog.yaml',
});
expect(
parseUrl('https://gitlab.com/blob/*/subfolder/catalog.yaml'),
).toEqual({
group: undefined,
host: 'gitlab.com',
branch: '*',
catalogPath: 'subfolder/catalog.yaml',
});
});
it('throws on incorrectly formed URLs', () => {
expect(() => parseUrl('https://gitlab.com')).toThrow();
expect(() => parseUrl('https://gitlab.com//')).toThrow();
expect(() => parseUrl('https://gitlab.com/foo')).toThrow();
expect(() => parseUrl('https://gitlab.com//foo')).toThrow();
expect(() => parseUrl('https://gitlab.com/org/teams')).toThrow();
expect(() => parseUrl('https://gitlab.com/org//teams')).toThrow();
expect(() =>
parseUrl('https://gitlab.com/org//teams/blob/catalog.yaml'),
).toThrow();
});
});
describe('handles repositories', () => {
it('pages through all repositories', async () => {
const processor = getProcessor();
setupFakeServer(PROJECTS_URL, request => {
switch (request.page) {
case 1:
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/1',
},
],
nextPage: 2,
};
case 2:
return {
data: [
{
id: 2,
archived: false,
default_branch: 'master',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/2',
},
{
id: 3,
archived: true, // ARCHIVED
default_branch: 'master',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/3',
},
],
};
default:
throw new Error('Invalid request');
}
});
const result: any[] = [];
await processor.readLocation(PROJECT_LOCATION, false, e => {
result.push(e);
});
expect(result).toEqual([
{
type: 'location',
location: {
type: 'url',
target: 'https://gitlab.fake/1/-/blob/main/catalog-info.yaml',
},
optional: true,
},
{
type: 'location',
location: {
type: 'url',
target: 'https://gitlab.fake/2/-/blob/master/catalog-info.yaml',
},
optional: true,
},
]);
});
it('can force a branch name', async () => {
const processor = getProcessor();
setupFakeServer(PROJECTS_URL, request => {
switch (request.page) {
case 1:
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/1',
},
],
};
default:
throw new Error('Invalid request');
}
});
const result: any[] = [];
await processor.readLocation(PROJECT_LOCATION_MASTER_BRANCH, false, e => {
result.push(e);
});
expect(result).toEqual([
{
type: 'location',
location: {
type: 'url',
target: 'https://gitlab.fake/1/-/blob/master/catalog-info.yaml',
},
optional: true,
},
]);
});
it('can filter based on group', async () => {
const processor = getProcessor();
setupFakeServer(GROUP_PROJECTS_URL, request => {
if (!request.include_subgroups) {
throw new Error('include_subgroups should be set');
}
switch (request.page) {
case 1:
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/1',
},
],
};
default:
throw new Error('Invalid request');
}
});
const result: any[] = [];
await processor.readLocation(GROUP_LOCATION, false, e => {
result.push(e);
});
// If everything was set up correctly, we should have received the fake repo specified above
expect(result).toHaveLength(1);
});
it('uses the previous scan timestamp to filter', async () => {
const processor = getProcessor();
setupFakeServer(PROJECTS_URL, request => {
switch (request.page) {
case 1:
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2000-01-01T00:00:00Z',
web_url: 'https://gitlab.fake/1',
},
{
id: 2,
archived: false,
default_branch: 'main',
last_activity_at: '2002-01-01T00:00:00Z',
web_url: 'https://gitlab.fake/2',
},
],
};
default:
throw new Error('Invalid request');
}
});
const result: any[] = [];
// First scan should find all repos, since no last activity was cached
await processor.readLocation(PROJECT_LOCATION, false, e => {
result.push(e);
});
expect(result).toHaveLength(2);
// Second scan should have used the mocked Date to set the last scanned time to 2001
// This should result in only the second repo being scanned, since that has a timestamp of 2002
const result2: any[] = [];
await processor.readLocation(PROJECT_LOCATION, false, e => {
result2.push(e);
});
expect(result2).toHaveLength(1);
});
});
describe('handles failure', () => {
it('invalid token', async () => {
// Setup an empty fake gitlab, since we don't care about actual results
setupFakeServer(PROJECTS_URL, _ => {
return {
data: [],
};
});
const config = getConfig();
config.integrations.gitlab[0].token = 'invalid';
await expect(
getProcessor(config).readLocation(PROJECT_LOCATION, false, _ => {}),
).rejects.toThrow(/Unauthorized/);
});
it('missing integration', async () => {
const config = getConfig();
delete config.integrations;
await expect(
getProcessor(config).readLocation(PROJECT_LOCATION, false, _ => {}),
).rejects.toThrow(/no GitLab integration/);
});
it('location type', async () => {
const incorrectLocation: LocationSpec = {
type: 'something-that-is-not-gitlab-discovery',
target: 'https://gitlab.fake/oh-dear',
};
await expect(
getProcessor().readLocation(incorrectLocation, false, _ => {}),
).resolves.toBeFalsy();
});
});
});
@@ -0,0 +1,170 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LocationSpec } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { Logger } from 'winston';
import * as results from './results';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
import { GitLabClient, GitLabProject, paginated } from './gitlab';
import {
CacheClient,
CacheManager,
PluginCacheManager,
} from '@backstage/backend-common';
/**
* Extracts repositories out of an GitLab instance.
*/
export class GitLabDiscoveryProcessor implements CatalogProcessor {
private readonly integrations: ScmIntegrations;
private readonly logger: Logger;
private readonly cache: CacheClient;
static fromConfig(config: Config, options: { logger: Logger }) {
const integrations = ScmIntegrations.fromConfig(config);
const pluginCache =
CacheManager.fromConfig(config).forPlugin('gitlab-discovery');
return new GitLabDiscoveryProcessor({
...options,
integrations,
pluginCache,
});
}
private constructor(options: {
integrations: ScmIntegrations;
pluginCache: PluginCacheManager;
logger: Logger;
}) {
this.integrations = options.integrations;
this.cache = options.pluginCache.getClient();
this.logger = options.logger;
}
async readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
): Promise<boolean> {
if (location.type !== 'gitlab-discovery') {
return false;
}
const { group, host, branch, catalogPath } = parseUrl(location.target);
const integration = this.integrations.gitlab.byUrl(`https://${host}`);
if (!integration) {
throw new Error(
`There is no GitLab integration that matches ${host}. Please add a configuration entry for it under integrations.gitlab`,
);
}
const client = new GitLabClient({
config: integration.config,
logger: this.logger,
});
const startTimestamp = Date.now();
this.logger.debug(`Reading GitLab projects from ${location.target}`);
const projects = paginated(options => client.listProjects(options), {
group,
last_activity_after: await this.updateLastActivity(),
page: 1,
});
const result: Result = {
scanned: 0,
matches: [],
};
for await (const project of projects) {
result.scanned++;
if (!project.archived) {
result.matches.push(project);
}
}
for (const project of result.matches) {
const project_branch = branch === '*' ? project.default_branch : branch;
emit(
results.location(
{
type: 'url',
// The format expected by the GitLabUrlReader:
// https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath
//
// This unfortunately will trigger another API call in `getGitLabFileFetchUrl` to get the project ID.
// The alternative is using the `buildRawUrl` function, which does not support subgroups, so providing a raw
// URL here won't work either.
target: `${project.web_url}/-/blob/${project_branch}/${catalogPath}`,
},
true,
),
);
}
const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1);
this.logger.debug(
`Read ${result.scanned} GitLab repositories in ${duration} seconds`,
);
return true;
}
async updateLastActivity(): Promise<string | undefined> {
const lastActivity = await this.cache.get('last-activity');
await this.cache.set('last-activity', new Date().toISOString());
return lastActivity as string | undefined;
}
}
type Result = {
scanned: number;
matches: GitLabProject[];
};
/*
* Helpers
*/
export function parseUrl(urlString: string): {
group?: string;
host: string;
branch: string;
catalogPath: string;
} {
const url = new URL(urlString);
const path = url.pathname.substr(1).split('/');
// (/group/subgroup)/blob/branch|*/filepath
const blobIndex = path.findIndex(p => p === 'blob');
if (blobIndex !== -1 && path.length > blobIndex + 2) {
const group =
blobIndex > 0 ? path.slice(0, blobIndex).join('/') : undefined;
return {
group,
host: url.host,
branch: decodeURIComponent(path[blobIndex + 1]),
catalogPath: decodeURIComponent(path.slice(blobIndex + 2).join('/')),
};
}
throw new Error(`Failed to parse ${urlString}`);
}
@@ -35,7 +35,8 @@ describe('GithubDiscoveryProcessor', () => {
org: 'foo',
host: 'github.com',
repoSearchPath: /^proj$/,
catalogPath: '/blob/master/catalog.yaml',
branch: 'master',
catalogPath: '/catalog.yaml',
});
expect(
parseUrl('https://github.com/foo/proj*/blob/master/catalog.yaml'),
@@ -43,14 +44,21 @@ describe('GithubDiscoveryProcessor', () => {
org: 'foo',
host: 'github.com',
repoSearchPath: /^proj.*$/,
catalogPath: '/blob/master/catalog.yaml',
branch: 'master',
catalogPath: '/catalog.yaml',
});
expect(parseUrl('https://github.com/foo')).toEqual({
org: 'foo',
host: 'github.com',
repoSearchPath: /^.*$/,
branch: '-',
catalogPath: '/catalog-info.yaml',
});
});
it('throws on incorrectly formed URLs', () => {
expect(() => parseUrl('https://github.com')).toThrow();
expect(() => parseUrl('https://github.com//')).toThrow();
expect(() => parseUrl('https://github.com/foo')).toThrow();
expect(() => parseUrl('https://github.com//foo')).toThrow();
expect(() => parseUrl('https://github.com/org/teams')).toThrow();
expect(() => parseUrl('https://github.com/org//teams')).toThrow();
@@ -125,11 +133,17 @@ describe('GithubDiscoveryProcessor', () => {
name: 'backstage',
url: 'https://github.com/backstage/backstage',
isArchived: false,
defaultBranchRef: {
name: 'master',
},
},
{
name: 'demo',
url: 'https://github.com/backstage/demo',
isArchived: false,
defaultBranchRef: {
name: 'main',
},
},
],
});
@@ -168,16 +182,25 @@ describe('GithubDiscoveryProcessor', () => {
name: 'backstage',
url: 'https://github.com/backstage/backstage',
isArchived: false,
defaultBranchRef: {
name: 'main',
},
},
{
name: 'techdocs-cli',
url: 'https://github.com/backstage/techdocs-cli',
isArchived: false,
defaultBranchRef: {
name: 'main',
},
},
{
name: 'techdocs-container',
url: 'https://github.com/backstage/techdocs-container',
isArchived: false,
defaultBranchRef: {
name: 'main',
},
},
],
});
@@ -215,21 +238,33 @@ describe('GithubDiscoveryProcessor', () => {
name: 'abstest',
url: 'https://github.com/backstage/abctest',
isArchived: false,
defaultBranchRef: {
name: 'main',
},
},
{
name: 'test',
url: 'https://github.com/backstage/test',
isArchived: false,
defaultBranchRef: {
name: 'main',
},
},
{
name: 'test-archived',
url: 'https://github.com/backstage/test',
isArchived: true,
defaultBranchRef: {
name: 'main',
},
},
{
name: 'testxyz',
url: 'https://github.com/backstage/testxyz',
isArchived: false,
defaultBranchRef: {
name: 'main',
},
},
],
});
@@ -28,7 +28,18 @@ import { CatalogProcessor, CatalogProcessorEmit } from './types';
/**
* Extracts repositories out of a GitHub org.
*/
*
* The following will create locations for all projects which have a catalog-info.yaml
* on the default branch. The first is shorthand for the second.
*
* target: "https://github.com/backstage"
* or
* target: https://github.com/backstage/*\/blob/-/catalog-info.yaml
*
* You may also explicitly specify the source branch:
*
* target: https://github.com/backstage/*\/blob/main/catalog-info.yaml
**/
export class GithubDiscoveryProcessor implements CatalogProcessor {
private readonly integrations: ScmIntegrations;
private readonly logger: Logger;
@@ -65,7 +76,7 @@ export class GithubDiscoveryProcessor implements CatalogProcessor {
);
}
const { org, repoSearchPath, catalogPath, host } = parseUrl(
const { org, repoSearchPath, catalogPath, branch, host } = parseUrl(
location.target,
);
@@ -97,11 +108,14 @@ export class GithubDiscoveryProcessor implements CatalogProcessor {
);
for (const repository of matching) {
const path = `/blob/${
branch === '-' ? repository.defaultBranchRef.name : branch
}${catalogPath}`;
emit(
results.location(
{
type: 'url',
target: `${repository.url}${catalogPath}`,
target: `${repository.url}${path}`,
},
// Not all locations may actually exist, since the user defined them as a wildcard pattern.
// Thus, we emit them as optional and let the downstream processor find them while not outputting
@@ -123,19 +137,31 @@ export function parseUrl(urlString: string): {
org: string;
repoSearchPath: RegExp;
catalogPath: string;
branch: string;
host: string;
} {
const url = new URL(urlString);
const path = url.pathname.substr(1).split('/');
// /backstage/techdocs-*/blob/master/catalog-info.yaml
// can also be
// /backstage
if (path.length > 2 && path[0].length && path[1].length) {
return {
org: decodeURIComponent(path[0]),
repoSearchPath: escapeRegExp(decodeURIComponent(path[1])),
catalogPath: `/${decodeURIComponent(path.slice(2).join('/'))}`,
branch: decodeURIComponent(path[3]),
catalogPath: `/${decodeURIComponent(path.slice(4).join('/'))}`,
host: url.host,
};
} else if (path.length === 1 && path[0].length) {
return {
org: decodeURIComponent(path[0]),
host: url.host,
repoSearchPath: escapeRegExp('*'),
catalogPath: '/catalog-info.yaml',
branch: '-',
};
}
throw new Error(`Failed to parse ${urlString}`);
@@ -201,11 +201,17 @@ describe('github', () => {
name: 'backstage',
url: 'https://github.com/backstage/backstage',
isArchived: false,
defaultBranchRef: {
name: 'main',
},
},
{
name: 'demo',
url: 'https://github.com/backstage/demo',
isArchived: true,
defaultBranchRef: {
name: 'main',
},
},
],
pageInfo: {
@@ -221,11 +227,17 @@ describe('github', () => {
name: 'backstage',
url: 'https://github.com/backstage/backstage',
isArchived: false,
defaultBranchRef: {
name: 'main',
},
},
{
name: 'demo',
url: 'https://github.com/backstage/demo',
isArchived: true,
defaultBranchRef: {
name: 'main',
},
},
],
};
@@ -60,6 +60,9 @@ export type Repository = {
name: string;
url: string;
isArchived: boolean;
defaultBranchRef: {
name: string;
};
};
export type Connection<T> = {
@@ -252,6 +255,9 @@ export async function getOrganizationRepositories(
name
url
isArchived
defaultBranchRef {
name
}
}
pageInfo {
hasNextPage
@@ -0,0 +1,107 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fetch from 'cross-fetch';
import {
getGitLabRequestOptions,
GitLabIntegrationConfig,
} from '@backstage/integration';
import { Logger } from 'winston';
export class GitLabClient {
private readonly config: GitLabIntegrationConfig;
private readonly logger: Logger;
constructor(options: { config: GitLabIntegrationConfig; logger: Logger }) {
this.config = options.config;
this.logger = options.logger;
}
async listProjects(options?: ListOptions): Promise<PagedResponse<any>> {
if (options?.group) {
return this.pagedRequest(
`${this.config.apiBaseUrl}/groups/${encodeURIComponent(
options?.group,
)}/projects`,
{
...options,
include_subgroups: true,
},
);
}
return this.pagedRequest(`${this.config.apiBaseUrl}/projects`, options);
}
private async pagedRequest(
endpoint: string,
options?: ListOptions,
): Promise<PagedResponse<any>> {
const request = new URL(endpoint);
for (const key in options) {
if (options[key]) {
request.searchParams.append(key, options[key]!.toString());
}
}
this.logger.debug(`Fetching: ${request.toString()}`);
const response = await fetch(
request.toString(),
getGitLabRequestOptions(this.config),
);
if (!response.ok) {
throw new Error(
`Unexpected response when fetching ${request.toString()}. Expected 200 but got ${
response.status
} - ${response.statusText}`,
);
}
return response.json().then(items => {
const nextPage = response.headers.get('x-next-page');
return {
items,
nextPage: nextPage ? Number(nextPage) : null,
} as PagedResponse<any>;
});
}
}
export type ListOptions = {
[key: string]: string | number | boolean | undefined;
group?: string;
per_page?: number | undefined;
page?: number | undefined;
};
export type PagedResponse<T> = {
items: T[];
nextPage?: number;
};
export async function* paginated(
request: (options: ListOptions) => Promise<PagedResponse<any>>,
options: ListOptions,
) {
let res;
do {
res = await request(options);
options.page = res.nextPage;
for (const item of res.items) {
yield item;
}
} while (res.nextPage);
}
@@ -0,0 +1,18 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { GitLabClient, paginated } from './client';
export type { GitLabProject } from './types';
@@ -0,0 +1,23 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type GitLabProject = {
id: number;
default_branch: string;
archived: boolean;
last_activity_at: string;
web_url: string;
};
@@ -26,6 +26,7 @@ export { FileReaderProcessor } from './FileReaderProcessor';
export { GithubDiscoveryProcessor } from './GithubDiscoveryProcessor';
export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor';
export { GithubMultiOrgReaderProcessor } from './GithubMultiOrgReaderProcessor';
export { GitLabDiscoveryProcessor } from './GitLabDiscoveryProcessor';
export { LocationEntityProcessor } from './LocationEntityProcessor';
export { PlaceholderProcessor } from './PlaceholderProcessor';
export type { PlaceholderResolver } from './PlaceholderProcessor';
@@ -50,6 +50,7 @@ import {
FileReaderProcessor,
GithubDiscoveryProcessor,
GithubOrgReaderProcessor,
GitLabDiscoveryProcessor,
PlaceholderProcessor,
PlaceholderResolver,
UrlReaderProcessor,
@@ -395,6 +396,7 @@ export class NextCatalogBuilder {
BitbucketDiscoveryProcessor.fromConfig(config, { logger }),
GithubDiscoveryProcessor.fromConfig(config, { logger }),
GithubOrgReaderProcessor.fromConfig(config, { logger }),
GitLabDiscoveryProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
CodeOwnersProcessor.fromConfig(config, { logger, reader }),
new AnnotateLocationEntityProcessor({ integrations }),
@@ -46,6 +46,7 @@ import {
FileReaderProcessor,
GithubDiscoveryProcessor,
GithubOrgReaderProcessor,
GitLabDiscoveryProcessor,
HigherOrderOperation,
HigherOrderOperations,
LocationEntityProcessor,
@@ -316,6 +317,7 @@ export class CatalogBuilder {
BitbucketDiscoveryProcessor.fromConfig(config, { logger }),
GithubDiscoveryProcessor.fromConfig(config, { logger }),
GithubOrgReaderProcessor.fromConfig(config, { logger }),
GitLabDiscoveryProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
CodeOwnersProcessor.fromConfig(config, { logger, reader }),
new LocationEntityProcessor({ integrations }),
@@ -22,6 +22,7 @@ import React, {
PropsWithChildren,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { useSearchParams } from 'react-router-dom';
@@ -197,18 +198,29 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
[],
);
const value = useMemo(
() => ({
filters: outputState.appliedFilters,
entities: outputState.entities,
backendEntities: outputState.backendEntities,
updateFilters,
queryParameters: outputState.queryParameters,
loading,
error,
}),
[
outputState.appliedFilters,
outputState.entities,
outputState.backendEntities,
updateFilters,
outputState.queryParameters,
loading,
error,
],
);
return (
<EntityListContext.Provider
value={{
filters: outputState.appliedFilters,
entities: outputState.entities,
backendEntities: outputState.backendEntities,
updateFilters,
queryParameters: outputState.queryParameters,
loading,
error,
}}
>
<EntityListContext.Provider value={value}>
{children}
</EntityListContext.Provider>
);
+513
View File
@@ -7,9 +7,145 @@
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
// Warning: (ae-missing-release-tag) "calverRegexp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const calverRegexp: RegExp;
// Warning: (ae-forgotten-export) The symbol "GetCommitResult" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "createMockCommit" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const createMockCommit: (
overrides: Partial<GetCommitResult['commit']>,
) => GetCommitResult;
// Warning: (ae-forgotten-export) The symbol "GetTagResult" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "createMockTag" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const createMockTag: (overrides: Partial<GetTagResult['tag']>) => GetTagResult;
// Warning: (ae-forgotten-export) The symbol "DifferProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "Differ" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const Differ: ({ current, next, icon }: DifferProps) => JSX.Element;
// Warning: (ae-missing-release-tag) "DISABLE_CACHE" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const DISABLE_CACHE: {
readonly headers: {
readonly 'If-None-Match': '';
};
};
// Warning: (ae-missing-release-tag) "Divider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const Divider: () => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "SemverTagParts" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "getBumpedSemverTagParts" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
function getBumpedSemverTagParts(
tagParts: SemverTagParts,
semverBumpLevel: keyof typeof SEMVER_PARTS,
): {
bumpedTagParts: {
prefix: string;
major: number;
minor: number;
patch: number;
};
};
// Warning: (ae-missing-release-tag) "getBumpedTag" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
function getBumpedTag({
project,
tag,
bumpLevel,
}: {
project: Project;
tag: string;
bumpLevel: keyof typeof SEMVER_PARTS;
}):
| {
bumpedTag: string;
tagParts: CalverTagParts;
error: undefined;
}
| {
bumpedTag: string;
tagParts: {
prefix: string;
major: number;
minor: number;
patch: number;
};
error: undefined;
}
| {
error: AlertError;
};
// Warning: (ae-missing-release-tag) "getCalverTagParts" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function getCalverTagParts(tag: string):
| {
error: AlertError;
tagParts?: undefined;
}
| {
tagParts: CalverTagParts;
error?: undefined;
};
// Warning: (ae-missing-release-tag) "getSemverTagParts" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function getSemverTagParts(tag: string):
| {
error: AlertError;
tagParts?: undefined;
}
| {
tagParts: SemverTagParts;
error?: undefined;
};
// Warning: (ae-missing-release-tag) "getShortCommitHash" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function getShortCommitHash(hash: string): string;
// Warning: (ae-missing-release-tag) "getTagParts" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
function getTagParts({ project, tag }: { project: Project; tag: string }):
| {
error: AlertError;
tagParts?: undefined;
}
| {
tagParts: CalverTagParts;
error?: undefined;
}
| {
tagParts: SemverTagParts;
error?: undefined;
};
// Warning: (ae-forgotten-export) The symbol "GitReleaseApi" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "gitReleaseManagerApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -32,5 +168,382 @@ export const gitReleaseManagerPlugin: BackstagePlugin<
{}
>;
// Warning: (ae-missing-release-tag) "InfoCardPlus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const InfoCardPlus: ({
children,
}: {
children?: React_2.ReactNode;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "internals" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const internals: {
components: typeof components;
constants: typeof constants;
helpers: typeof helpers;
testHelpers: typeof testHelpers;
};
// Warning: (ae-missing-release-tag) "isCalverTagParts" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function isCalverTagParts(
project: Project,
_tagParts: unknown,
): _tagParts is CalverTagParts;
// Warning: (ae-missing-release-tag) "isProjectValid" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function isProjectValid(project: any): project is Project;
// Warning: (ae-missing-release-tag) "LinearProgressWithLabel" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function LinearProgressWithLabel(props: {
progress: number;
responseSteps: ResponseStep[];
}): JSX.Element;
// Warning: (ae-missing-release-tag) "mockApiClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
const mockApiClient: GitReleaseApi;
// Warning: (ae-missing-release-tag) "mockBumpedTag" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockBumpedTag = 'rc-2020.01.01_1337';
// Warning: (ae-missing-release-tag) "mockCalverProject" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockCalverProject: Project;
// Warning: (ae-missing-release-tag) "mockDefaultBranch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockDefaultBranch = 'mock_defaultBranch';
// Warning: (ae-forgotten-export) The symbol "getReleaseCandidateGitInfo" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "mockNextGitInfoCalver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockNextGitInfoCalver: ReturnType<typeof getReleaseCandidateGitInfo>;
// Warning: (ae-missing-release-tag) "mockNextGitInfoSemver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockNextGitInfoSemver: ReturnType<typeof getReleaseCandidateGitInfo>;
// Warning: (ae-missing-release-tag) "mockReleaseBranch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockReleaseBranch: {
name: string;
links: {
html: string;
};
commit: {
sha: string;
commit: {
tree: {
sha: string;
};
};
};
};
// Warning: (ae-missing-release-tag) "mockReleaseCandidateCalver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockReleaseCandidateCalver: {
targetCommitish: string;
tagName: string;
prerelease: boolean;
id: number;
htmlUrl: string;
body?: string | null | undefined;
};
// Warning: (ae-missing-release-tag) "mockReleaseCandidateSemver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockReleaseCandidateSemver: {
targetCommitish: string;
tagName: string;
prerelease: boolean;
id: number;
htmlUrl: string;
body?: string | null | undefined;
};
// Warning: (ae-forgotten-export) The symbol "ReleaseStats" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "mockReleaseStats" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockReleaseStats: ReleaseStats;
// Warning: (ae-missing-release-tag) "mockReleaseVersionCalver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockReleaseVersionCalver: {
targetCommitish: string;
tagName: string;
prerelease: boolean;
id: number;
htmlUrl: string;
body?: string | null | undefined;
};
// Warning: (ae-missing-release-tag) "mockReleaseVersionSemver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockReleaseVersionSemver: {
targetCommitish: string;
tagName: string;
prerelease: boolean;
id: number;
htmlUrl: string;
body?: string | null | undefined;
};
// Warning: (ae-missing-release-tag) "mockSearchCalver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockSearchCalver: string;
// Warning: (ae-missing-release-tag) "mockSearchSemver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockSearchSemver: string;
// Warning: (ae-missing-release-tag) "mockSelectedPatchCommit" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockSelectedPatchCommit: {
htmlUrl: string;
sha: string;
author: {
htmlUrl?: string | undefined;
login?: string | undefined;
};
commit: {
message: string;
};
firstParentSha?: string | undefined;
};
// Warning: (ae-missing-release-tag) "mockSemverProject" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockSemverProject: Project;
// Warning: (ae-missing-release-tag) "mockTagParts" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockTagParts: CalverTagParts;
// Warning: (ae-missing-release-tag) "mockUser" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const mockUser: {
username: string;
email: string;
};
// Warning: (ae-missing-release-tag) "NoLatestRelease" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const NoLatestRelease: () => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "DialogProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "ResponseStepDialog" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const ResponseStepDialog: ({
progress,
responseSteps,
title,
}: DialogProps) => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "ResponseStepListProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "ResponseStepList" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const ResponseStepList: ({
responseSteps,
animationDelay,
loading,
denseList,
children,
}: PropsWithChildren<ResponseStepListProps>) => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "ResponseStepListItemProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "ResponseStepListItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const ResponseStepListItem: ({
responseStep,
animationDelay,
}: ResponseStepListItemProps) => JSX.Element;
// Warning: (ae-missing-release-tag) "SEMVER_PARTS" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const SEMVER_PARTS: {
major: 'major';
minor: 'minor';
patch: 'patch';
};
// Warning: (ae-missing-release-tag) "semverRegexp" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const semverRegexp: RegExp;
declare namespace stats {
export { mockReleaseStats };
}
// Warning: (ae-missing-release-tag) "TAG_OBJECT_MESSAGE" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const TAG_OBJECT_MESSAGE =
'Tag generated by your friendly neighborhood Backstage Release Manager';
// Warning: (ae-missing-release-tag) "TEST_IDS" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const TEST_IDS: {
info: {
info: string;
infoFeaturePlus: string;
};
createRc: {
cta: string;
semverSelect: string;
};
promoteRc: {
mockedPromoteRcBody: string;
notRcWarning: string;
promoteRc: string;
cta: string;
};
patch: {
error: string;
loading: string;
notPrerelease: string;
body: string;
};
form: {
owner: {
loading: string;
select: string;
error: string;
empty: string;
};
repo: {
loading: string;
select: string;
error: string;
empty: string;
};
versioningStrategy: {
radioGroup: string;
};
};
components: {
divider: string;
noLatestRelease: string;
circularProgress: string;
responseStepListDialogContent: string;
responseStepListItem: string;
responseStepListItemIconSuccess: string;
responseStepListItemIconFailure: string;
responseStepListItemIconLink: string;
responseStepListItemIconDefault: string;
differ: {
current: string;
next: string;
icons: {
tag: string;
branch: string;
github: string;
slack: string;
versioning: string;
};
};
linearProgressWithLabel: string;
};
};
declare namespace testHelpers_2 {
export {
createMockTag,
createMockCommit,
mockUser,
mockSemverProject,
mockCalverProject,
mockSearchCalver,
mockSearchSemver,
mockDefaultBranch,
mockNextGitInfoSemver,
mockNextGitInfoCalver,
mockTagParts,
mockBumpedTag,
mockReleaseCandidateCalver,
mockReleaseVersionCalver,
mockReleaseCandidateSemver,
mockReleaseVersionSemver,
mockReleaseBranch,
mockSelectedPatchCommit,
mockApiClient,
};
}
declare namespace testIds {
export { TEST_IDS };
}
// Warning: (ae-missing-release-tag) "validateTagName" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const validateTagName: ({
project,
tagName,
}: {
project: Project;
tagName?: string | undefined;
}) =>
| {
tagNameError: null;
}
| {
tagNameError: AlertError | undefined;
};
// Warning: (ae-missing-release-tag) "VERSIONING_STRATEGIES" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const VERSIONING_STRATEGIES: {
semver: 'semver';
calver: 'calver';
};
// Warnings were encountered during analysis:
//
// src/components/ResponseStepDialog/LinearProgressWithLabel.d.ts:5:5 - (ae-forgotten-export) The symbol "ResponseStep" needs to be exported by the entry point index.d.ts
// src/helpers/getBumpedTag.d.ts:14:5 - (ae-forgotten-export) The symbol "Project" needs to be exported by the entry point index.d.ts
// src/helpers/getBumpedTag.d.ts:19:5 - (ae-forgotten-export) The symbol "CalverTagParts" needs to be exported by the entry point index.d.ts
// src/helpers/getBumpedTag.d.ts:31:5 - (ae-forgotten-export) The symbol "AlertError" needs to be exported by the entry point index.d.ts
// src/index.d.ts:4:5 - (ae-forgotten-export) The symbol "components" needs to be exported by the entry point index.d.ts
// src/index.d.ts:5:5 - (ae-forgotten-export) The symbol "constants" needs to be exported by the entry point index.d.ts
// src/index.d.ts:6:5 - (ae-forgotten-export) The symbol "helpers" needs to be exported by the entry point index.d.ts
// src/index.d.ts:7:5 - (ae-forgotten-export) The symbol "testHelpers" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,24 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Differ } from './Differ';
export { Divider } from './Divider';
export { InfoCardPlus } from './InfoCardPlus';
export { LinearProgressWithLabel } from './ResponseStepDialog/LinearProgressWithLabel';
export { NoLatestRelease } from './NoLatestRelease';
export { ResponseStepDialog } from './ResponseStepDialog/ResponseStepDialog';
export { ResponseStepList } from './ResponseStepDialog/ResponseStepList';
export { ResponseStepListItem } from './ResponseStepDialog/ResponseStepListItem';
@@ -21,6 +21,14 @@ import { Project } from '../contexts/ProjectContext';
import { SEMVER_PARTS } from '../constants/constants';
import { SemverTagParts } from './tagParts/getSemverTagParts';
/**
* Calculates the next version for the project
*
* For calendar versioning this means a bump in patch
*
* For semantic versioning this means either a minor or a patch bump
* depending on the value of `bumpLevel`
*/
export function getBumpedTag({
project,
tag,
@@ -74,6 +82,10 @@ function getBumpedSemverTag(
};
}
/**
* Calculates the next semantic version, taking into account
* whether or not it's a minor or patch
*/
export function getBumpedSemverTagParts(
tagParts: SemverTagParts,
semverBumpLevel: keyof typeof SEMVER_PARTS,
@@ -0,0 +1,24 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { calverRegexp, getCalverTagParts } from './tagParts/getCalverTagParts';
export { getBumpedSemverTagParts, getBumpedTag } from './getBumpedTag';
export { getSemverTagParts, semverRegexp } from './tagParts/getSemverTagParts';
export { getShortCommitHash } from './getShortCommitHash';
export { getTagParts } from './tagParts/getTagParts';
export { isCalverTagParts } from './isCalverTagParts';
export { isProjectValid } from './isProjectValid';
export { validateTagName } from './tagParts/validateTagName';
@@ -18,6 +18,10 @@ import { getCalverTagParts } from './getCalverTagParts';
import { getSemverTagParts } from './getSemverTagParts';
import { Project } from '../../contexts/ProjectContext';
/**
* Tag parts are the individual parts of a version, e.g. <major>.<minor>.<patch>
* are the parts of a semantic version
*/
export function getTagParts({
project,
tag,
+9
View File
@@ -19,3 +19,12 @@ export {
GitReleaseManagerPage,
gitReleaseManagerApiRef,
} from './plugin';
import { components, constants, helpers, testHelpers } from './plugin';
export const internals = {
components,
constants,
helpers,
testHelpers,
};
@@ -21,6 +21,10 @@ describe('git-release-manager', () => {
expect(Object.keys(plugin)).toMatchInlineSnapshot(`
Array [
"gitReleaseManagerApiRef",
"constants",
"helpers",
"components",
"testHelpers",
"gitReleaseManagerPlugin",
"GitReleaseManagerPage",
]
+9 -5
View File
@@ -14,10 +14,6 @@
* limitations under the License.
*/
import { gitReleaseManagerApiRef } from './api/serviceApiRef';
import { GitReleaseClient } from './api/GitReleaseClient';
import { rootRouteRef } from './routes';
import {
configApiRef,
createPlugin,
@@ -26,7 +22,15 @@ import {
createRoutableExtension,
} from '@backstage/core-plugin-api';
export { gitReleaseManagerApiRef };
import { GitReleaseClient } from './api/GitReleaseClient';
import { gitReleaseManagerApiRef } from './api/serviceApiRef';
import { rootRouteRef } from './routes';
import * as constants from './constants/constants';
import * as helpers from './helpers';
import * as components from './components';
import * as testHelpers from './test-helpers';
export { gitReleaseManagerApiRef, constants, helpers, components, testHelpers };
export const gitReleaseManagerPlugin = createPlugin({
id: 'git-release-manager',
@@ -0,0 +1,21 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as stats from './stats';
import * as testHelpers from './test-helpers';
import * as testIds from './test-ids';
export { stats, testHelpers, testIds };
@@ -115,6 +115,14 @@ describe('ConfigClusterLocator', () => {
authProvider: 'aws',
skipTLSVerify: true,
},
{
assumeRole: 'SomeRole',
name: 'cluster2',
externalId: 'SomeExternalId',
url: 'http://localhost:8081',
authProvider: 'aws',
skipTLSVerify: true,
},
],
});
@@ -127,6 +135,7 @@ describe('ConfigClusterLocator', () => {
assumeRole: undefined,
name: 'cluster1',
serviceAccountToken: 'token',
externalId: undefined,
url: 'http://localhost:8080',
authProvider: 'aws',
skipTLSVerify: false,
@@ -134,11 +143,21 @@ describe('ConfigClusterLocator', () => {
{
assumeRole: 'SomeRole',
name: 'cluster2',
externalId: undefined,
serviceAccountToken: undefined,
url: 'http://localhost:8081',
authProvider: 'aws',
skipTLSVerify: true,
},
{
assumeRole: 'SomeRole',
name: 'cluster2',
externalId: 'SomeExternalId',
url: 'http://localhost:8081',
serviceAccountToken: undefined,
authProvider: 'aws',
skipTLSVerify: true,
},
]);
});
});
@@ -44,7 +44,9 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier {
}
case 'aws': {
const assumeRole = c.getOptionalString('assumeRole');
return { assumeRole, ...clusterDetails };
const externalId = c.getOptionalString('externalId');
return { assumeRole, externalId, ...clusterDetails };
}
case 'serviceAccount': {
return clusterDetails;
@@ -65,6 +65,17 @@ describe('PgSearchEngine', () => {
});
});
it('should sanitize query term', async () => {
const actualTranslatedQuery = searchEngine.translator({
term: 'H&e|l!l*o W\0o(r)l:d',
pageCursor: '',
}) as PgSearchQuery;
expect(actualTranslatedQuery).toMatchObject({
pgTerm: '("Hello" | "Hello":*)&("World" | "World":*)',
});
});
it('should return translated query with filters', async () => {
const actualTranslatedQuery = searchEngine.translator({
term: 'testTerm',
@@ -50,7 +50,7 @@ export class PgSearchEngine implements SearchEngine {
return {
pgTerm: query.term
.split(/\s/)
.map(p => p.trim())
.map(p => p.replace(/[\0()|&:*!]/g, '').trim())
.filter(p => p !== '')
.map(p => `(${JSON.stringify(p)} | ${JSON.stringify(p)}:*)`)
.join('&'),
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { ChangeEvent, useState } from 'react';
import React, { ChangeEvent, useEffect, useState } from 'react';
import { useDebounce } from 'react-use';
import { InputBase, InputAdornment, IconButton } from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search';
@@ -31,6 +31,10 @@ export const SearchBar = ({ className, debounceTime = 0 }: Props) => {
const { term, setTerm } = useSearch();
const [value, setValue] = useState<string>(term);
useEffect(() => {
setValue(prevValue => (prevValue !== term ? term : prevValue));
}, [term]);
useDebounce(() => setTerm(value), debounceTime, [value]);
const handleQuery = (e: ChangeEvent<HTMLInputElement>) => {
@@ -54,7 +54,7 @@ export const SearchContextProvider = ({
term: '',
pageCursor: '',
filters: {},
types: ['*'],
types: [],
},
children,
}: PropsWithChildren<{ initialState?: SettableSearchContext }>) => {
@@ -18,7 +18,7 @@ import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { useLocation, useOutlet } from 'react-router';
import { useSearch, SearchContextProvider } from '../SearchContext';
import { useSearch } from '../SearchContext';
import { SearchPage } from './';
jest.mock('react-router', () => ({
@@ -29,6 +29,11 @@ jest.mock('react-router', () => ({
useOutlet: jest.fn().mockReturnValue('Route Children'),
}));
const setTermMock = jest.fn();
const setTypesMock = jest.fn();
const setFiltersMock = jest.fn();
const setPageCursorMock = jest.fn();
jest.mock('../SearchContext', () => ({
...jest.requireActual('../SearchContext'),
SearchContextProvider: jest
@@ -36,9 +41,13 @@ jest.mock('../SearchContext', () => ({
.mockImplementation(({ children }) => children),
useSearch: jest.fn().mockReturnValue({
term: '',
setTerm: (term: any) => setTermMock(term),
types: [],
setTypes: (types: any) => setTypesMock(types),
filters: {},
setFilters: (filters: any) => setFiltersMock(filters),
pageCursor: '',
setPageCursor: (pageCursor: any) => setPageCursorMock(pageCursor),
}),
}));
@@ -58,7 +67,7 @@ describe('SearchPage', () => {
window.history.replaceState = origReplaceState;
});
it('uses initial term state from location', async () => {
it('sets term state from location', async () => {
// Given this initial location.search value...
const expectedFilterField = 'anyKey';
const expectedFilterValue = 'anyValue';
@@ -75,13 +84,11 @@ describe('SearchPage', () => {
// When we render the page...
await renderInTestApp(<SearchPage />);
// Then search context should be initialized with these values...
const calls = (SearchContextProvider as jest.Mock).mock.calls[0];
const actualInitialState = calls[0].initialState;
expect(actualInitialState.term).toEqual(expectedTerm);
expect(actualInitialState.types).toEqual(expectedTypes);
expect(actualInitialState.pageCursor).toEqual(expectedPageCursor);
expect(actualInitialState.filters).toStrictEqual(expectedFilters);
// Then search context should be set with these values...
expect(setTermMock).toHaveBeenCalledWith(expectedTerm);
expect(setTypesMock).toHaveBeenCalledWith(expectedTypes);
expect(setPageCursorMock).toHaveBeenCalledWith(expectedPageCursor);
expect(setFiltersMock).toHaveBeenCalledWith(expectedFilters);
});
it('renders provided router element', async () => {
@@ -103,6 +110,10 @@ describe('SearchPage', () => {
types: ['software-catalog'],
pageCursor: 'page2-or-something',
filters: { anyKey: 'anyValue' },
setTerm: setTermMock,
setTypes: setTypesMock,
setFilters: setFiltersMock,
setPageCursor: setPageCursorMock,
});
const expectedLocation = encodeURI(
'?query=bieber&types[]=software-catalog&pageCursor=page2-or-something&filters[anyKey]=anyValue',
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { usePrevious } from 'react-use';
import qs from 'qs';
import { useLocation, useOutlet } from 'react-router';
import { SearchContextProvider, useSearch } from '../SearchContext';
@@ -22,46 +23,72 @@ import { JsonObject } from '@backstage/config';
import { LegacySearchPage } from '../LegacySearchPage';
export const UrlUpdater = () => {
const { term, types, pageCursor, filters } = useSearch();
const location = useLocation();
const {
term,
setTerm,
types,
setTypes,
pageCursor,
setPageCursor,
filters,
setFilters,
} = useSearch();
const newParams = qs.stringify(
{
query: term,
types,
pageCursor,
filters,
},
{ arrayFormat: 'brackets' },
);
const newUrl = `${window.location.pathname}?${newParams}`;
const prevQueryParams = usePrevious(location.search);
useEffect(() => {
// Only respond to changes to url query params
if (location.search === prevQueryParams) {
return;
}
// We directly manipulate window history here in order to not re-render
// infinitely (state => location => state => etc). The intention of this
// code is just to ensure the right query/filters are loaded when a user
// clicks the "back" button after clicking a result.
window.history.replaceState(null, document.title, newUrl);
const query =
qs.parse(location.search.substring(1), { arrayLimit: 0 }) || {};
if (query.filters) {
setFilters(query.filters as JsonObject);
}
if (query.query) {
setTerm(query.query as string);
}
if (query.pageCursor) {
setPageCursor(query.pageCursor as string);
}
if (query.types) {
setTypes(query.types as string[]);
}
}, [prevQueryParams, location, setTerm, setTypes, setPageCursor, setFilters]);
useEffect(() => {
const newParams = qs.stringify(
{
query: term,
types,
pageCursor,
filters,
},
{ arrayFormat: 'brackets' },
);
const newUrl = `${window.location.pathname}?${newParams}`;
// We directly manipulate window history here in order to not re-render
// infinitely (state => location => state => etc). The intention of this
// code is just to ensure the right query/filters are loaded when a user
// clicks the "back" button after clicking a result.
window.history.replaceState(null, document.title, newUrl);
}, [term, types, pageCursor, filters]);
return null;
};
export const SearchPage = () => {
const location = useLocation();
const outlet = useOutlet();
const query = qs.parse(location.search.substring(1), { arrayLimit: 0 }) || {};
const filters = (query.filters as JsonObject) || {};
const queryString = (query.query as string) || '';
const pageCursor = (query.pageCursor as string) || '';
const types = (query.types as string[]) || [];
const initialState = {
term: queryString || '',
types,
pageCursor,
filters,
};
return (
<SearchContextProvider initialState={initialState}>
<SearchContextProvider>
<UrlUpdater />
{outlet || <LegacySearchPage />}
</SearchContextProvider>