diff --git a/.changeset/big-jars-repair.md b/.changeset/big-jars-repair.md
new file mode 100644
index 0000000000..63fef2c740
--- /dev/null
+++ b/.changeset/big-jars-repair.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-backend': patch
+---
+
+GitHub discovery processor adds support for discovering the default GitHub branch
diff --git a/.changeset/eleven-snakes-give.md b/.changeset/eleven-snakes-give.md
new file mode 100644
index 0000000000..7c7a5724e6
--- /dev/null
+++ b/.changeset/eleven-snakes-give.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-search-backend-module-pg': patch
+---
+
+Sanitize special characters before building search query for postgres
diff --git a/.changeset/forty-peaches-cry.md b/.changeset/forty-peaches-cry.md
new file mode 100644
index 0000000000..f388c8a0cd
--- /dev/null
+++ b/.changeset/forty-peaches-cry.md
@@ -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.
diff --git a/.changeset/lemon-dogs-switch.md b/.changeset/lemon-dogs-switch.md
new file mode 100644
index 0000000000..aa86e3c2b0
--- /dev/null
+++ b/.changeset/lemon-dogs-switch.md
@@ -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.
diff --git a/.changeset/light-spoons-listen.md b/.changeset/light-spoons-listen.md
new file mode 100644
index 0000000000..ec3a85cb0d
--- /dev/null
+++ b/.changeset/light-spoons-listen.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-auth-backend': patch
+---
+
+Fixed a bug where OAuth state parameters would be serialized as the string `'undefined'`.
diff --git a/.changeset/lucky-gifts-help.md b/.changeset/lucky-gifts-help.md
new file mode 100644
index 0000000000..3ed6b12498
--- /dev/null
+++ b/.changeset/lucky-gifts-help.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-backend': patch
+---
+
+Added GitLabDiscoveryProcessor, which allows catalog discovery from a GitLab instance
diff --git a/.changeset/rich-mayflies-do.md b/.changeset/rich-mayflies-do.md
new file mode 100644
index 0000000000..d68e00c0aa
--- /dev/null
+++ b/.changeset/rich-mayflies-do.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-kubernetes-backend': patch
+---
+
+Fixes bug reading ExternalId from k8s backend config
diff --git a/.changeset/strange-ducks-rhyme.md b/.changeset/strange-ducks-rhyme.md
new file mode 100644
index 0000000000..1f75593d04
--- /dev/null
+++ b/.changeset/strange-ducks-rhyme.md
@@ -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.
diff --git a/.changeset/wild-olives-applaud.md b/.changeset/wild-olives-applaud.md
new file mode 100644
index 0000000000..14637cc842
--- /dev/null
+++ b/.changeset/wild-olives-applaud.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-search': patch
+---
+
+Fix search page to respond to searches made from sidebar search
diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt
index 57510f2c3f..dd9224fc84 100644
--- a/.github/styles/vocab.txt
+++ b/.github/styles/vocab.txt
@@ -142,6 +142,7 @@ maintainership
makefile
md
memcache
+memoize
memoized
microservice
microservices
@@ -215,6 +216,7 @@ repo
Repo
repos
rerender
+rerenders
Reusability
reusability
roadmaps
diff --git a/docs/integrations/gitlab/discovery.md b/docs/integrations/gitlab/discovery.md
new file mode 100644
index 0000000000..ade575b74d
--- /dev/null
+++ b/docs/integrations/gitlab/discovery.md
@@ -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`
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index d84a7341bc..4a392e0f05 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -211,20 +211,8 @@ const cicdCard = (
);
-const errorsContent = (
-
-
-
-
-
-
-
-
-
-);
-
-const overviewContent = (
-
+const entityWarningContent = (
+ <>
@@ -240,7 +228,24 @@ const overviewContent = (
+ >
+);
+const errorsContent = (
+
+
+
+
+
+
+
+
+
+);
+
+const overviewContent = (
+
+ {entityWarningContent}
@@ -448,6 +453,7 @@ const apiPage = (
+ {entityWarningContent}
@@ -478,6 +484,7 @@ const userPage = (
+ {entityWarningContent}
@@ -493,6 +500,7 @@ const groupPage = (
+ {entityWarningContent}
@@ -511,6 +519,7 @@ const systemPage = (
+ {entityWarningContent}
@@ -535,6 +544,7 @@ const domainPage = (
+ {entityWarningContent}
diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx
index a78d1a8f01..d3b4b786a0 100644
--- a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx
@@ -82,8 +82,8 @@ const cicdContent = (
);
-const overviewContent = (
-
+const entityWarningContent = (
+ <>
@@ -99,7 +99,12 @@ const overviewContent = (
+ >
+);
+const overviewContent = (
+
+ {entityWarningContent}
@@ -214,9 +219,13 @@ const apiPage = (
+ {entityWarningContent}
+
+
+
@@ -242,6 +251,7 @@ const userPage = (
+ {entityWarningContent}
@@ -257,6 +267,7 @@ const groupPage = (
+ {entityWarningContent}
@@ -275,6 +286,7 @@ const systemPage = (
+ {entityWarningContent}
@@ -299,6 +311,7 @@ const domainPage = (
+ {entityWarningContent}
diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json
index 26bd5b408e..38ad491b19 100644
--- a/plugins/auth-backend/package.json
+++ b/plugins/auth-backend/package.json
@@ -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",
diff --git a/plugins/auth-backend/src/lib/oauth/helpers.test.ts b/plugins/auth-backend/src/lib/oauth/helpers.test.ts
index 00161ff8cf..8af1d2f2ad 100644
--- a/plugins/auth-backend/src/lib/oauth/helpers.test.ts
+++ b/plugins/auth-backend/src/lib/oauth/helpers.test.ts
@@ -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' };
diff --git a/plugins/auth-backend/src/lib/oauth/helpers.ts b/plugins/auth-backend/src/lib/oauth/helpers.ts
index ead9acae27..17e3769d21 100644
--- a/plugins/auth-backend/src/lib/oauth/helpers.ts
+++ b/plugins/auth-backend/src/lib/oauth/helpers.ts
@@ -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');
};
diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md
index 723ea5c50c..a7a100db65 100644
--- a/plugins/catalog-backend/api-report.md
+++ b/plugins/catalog-backend/api-report.md
@@ -813,6 +813,27 @@ export class GithubOrgReaderProcessor implements CatalogProcessor {
): Promise;
}
+// 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;
+ // (undocumented)
+ updateLastActivity(): Promise;
+}
+
// 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)
diff --git a/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.test.ts
new file mode 100644
index 0000000000..a0e44d7648
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.ts
new file mode 100644
index 0000000000..445f02829e
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/processors/GitLabDiscoveryProcessor.ts
@@ -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 {
+ 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 {
+ 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}`);
+}
diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts
index ca8c702c8f..f8647cb5d2 100644
--- a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts
@@ -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',
+ },
},
],
});
diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts
index f7069a9f64..567f6536d0 100644
--- a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts
@@ -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}`);
diff --git a/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts
index 11cdf671c3..e481355fb4 100644
--- a/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts
@@ -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',
+ },
},
],
};
diff --git a/plugins/catalog-backend/src/ingestion/processors/github/github.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.ts
index 70479110c2..9eb5073710 100644
--- a/plugins/catalog-backend/src/ingestion/processors/github/github.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/github/github.ts
@@ -60,6 +60,9 @@ export type Repository = {
name: string;
url: string;
isArchived: boolean;
+ defaultBranchRef: {
+ name: string;
+ };
};
export type Connection = {
@@ -252,6 +255,9 @@ export async function getOrganizationRepositories(
name
url
isArchived
+ defaultBranchRef {
+ name
+ }
}
pageInfo {
hasNextPage
diff --git a/plugins/catalog-backend/src/ingestion/processors/gitlab/client.ts b/plugins/catalog-backend/src/ingestion/processors/gitlab/client.ts
new file mode 100644
index 0000000000..8781e071f7
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/processors/gitlab/client.ts
@@ -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> {
+ 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> {
+ 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;
+ });
+ }
+}
+
+export type ListOptions = {
+ [key: string]: string | number | boolean | undefined;
+ group?: string;
+ per_page?: number | undefined;
+ page?: number | undefined;
+};
+
+export type PagedResponse = {
+ items: T[];
+ nextPage?: number;
+};
+
+export async function* paginated(
+ request: (options: ListOptions) => Promise>,
+ options: ListOptions,
+) {
+ let res;
+ do {
+ res = await request(options);
+ options.page = res.nextPage;
+ for (const item of res.items) {
+ yield item;
+ }
+ } while (res.nextPage);
+}
diff --git a/plugins/catalog-backend/src/ingestion/processors/gitlab/index.ts b/plugins/catalog-backend/src/ingestion/processors/gitlab/index.ts
new file mode 100644
index 0000000000..1df3d2cb84
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/processors/gitlab/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { GitLabClient, paginated } from './client';
+export type { GitLabProject } from './types';
diff --git a/plugins/catalog-backend/src/ingestion/processors/gitlab/types.ts b/plugins/catalog-backend/src/ingestion/processors/gitlab/types.ts
new file mode 100644
index 0000000000..a66411ddc8
--- /dev/null
+++ b/plugins/catalog-backend/src/ingestion/processors/gitlab/types.ts
@@ -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;
+};
diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts
index 6a44ea0a8f..fe4d374829 100644
--- a/plugins/catalog-backend/src/ingestion/processors/index.ts
+++ b/plugins/catalog-backend/src/ingestion/processors/index.ts
@@ -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';
diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts
index 675e170727..ed690d07b6 100644
--- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts
+++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts
@@ -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 }),
diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts
index 76b4af7450..70c1890fbf 100644
--- a/plugins/catalog-backend/src/service/CatalogBuilder.ts
+++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts
@@ -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 }),
diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx
index 54d2fdcd47..d5c4668079 100644
--- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx
+++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx
@@ -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 = ({
[],
);
+ 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 (
-
+
{children}
);
diff --git a/plugins/git-release-manager/api-report.md b/plugins/git-release-manager/api-report.md
index 018a0f4be8..2c3b55a110 100644
--- a/plugins/git-release-manager/api-report.md
+++ b/plugins/git-release-manager/api-report.md
@@ -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;
+
+// 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;
+
+// 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;
+
+// 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;
+
+// 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) => 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)
```
diff --git a/plugins/git-release-manager/src/components/index.tsx b/plugins/git-release-manager/src/components/index.tsx
new file mode 100644
index 0000000000..2ce1edc6d2
--- /dev/null
+++ b/plugins/git-release-manager/src/components/index.tsx
@@ -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';
diff --git a/plugins/git-release-manager/src/helpers/getBumpedTag.ts b/plugins/git-release-manager/src/helpers/getBumpedTag.ts
index c368047635..dbfb00818e 100644
--- a/plugins/git-release-manager/src/helpers/getBumpedTag.ts
+++ b/plugins/git-release-manager/src/helpers/getBumpedTag.ts
@@ -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,
diff --git a/plugins/git-release-manager/src/helpers/index.tsx b/plugins/git-release-manager/src/helpers/index.tsx
new file mode 100644
index 0000000000..1516e192a1
--- /dev/null
+++ b/plugins/git-release-manager/src/helpers/index.tsx
@@ -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';
diff --git a/plugins/git-release-manager/src/helpers/tagParts/getTagParts.ts b/plugins/git-release-manager/src/helpers/tagParts/getTagParts.ts
index acedf2e8d1..750e4e23c5 100644
--- a/plugins/git-release-manager/src/helpers/tagParts/getTagParts.ts
+++ b/plugins/git-release-manager/src/helpers/tagParts/getTagParts.ts
@@ -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. ..
+ * are the parts of a semantic version
+ */
export function getTagParts({
project,
tag,
diff --git a/plugins/git-release-manager/src/index.ts b/plugins/git-release-manager/src/index.ts
index 767dc5c6e1..c325a3335b 100644
--- a/plugins/git-release-manager/src/index.ts
+++ b/plugins/git-release-manager/src/index.ts
@@ -19,3 +19,12 @@ export {
GitReleaseManagerPage,
gitReleaseManagerApiRef,
} from './plugin';
+
+import { components, constants, helpers, testHelpers } from './plugin';
+
+export const internals = {
+ components,
+ constants,
+ helpers,
+ testHelpers,
+};
diff --git a/plugins/git-release-manager/src/plugin.test.ts b/plugins/git-release-manager/src/plugin.test.ts
index 69e2c47c6a..db60617c24 100644
--- a/plugins/git-release-manager/src/plugin.test.ts
+++ b/plugins/git-release-manager/src/plugin.test.ts
@@ -21,6 +21,10 @@ describe('git-release-manager', () => {
expect(Object.keys(plugin)).toMatchInlineSnapshot(`
Array [
"gitReleaseManagerApiRef",
+ "constants",
+ "helpers",
+ "components",
+ "testHelpers",
"gitReleaseManagerPlugin",
"GitReleaseManagerPage",
]
diff --git a/plugins/git-release-manager/src/plugin.ts b/plugins/git-release-manager/src/plugin.ts
index 511fd4b92e..a416ab9092 100644
--- a/plugins/git-release-manager/src/plugin.ts
+++ b/plugins/git-release-manager/src/plugin.ts
@@ -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',
diff --git a/plugins/git-release-manager/src/test-helpers/index.ts b/plugins/git-release-manager/src/test-helpers/index.ts
new file mode 100644
index 0000000000..2a91928321
--- /dev/null
+++ b/plugins/git-release-manager/src/test-helpers/index.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as stats from './stats';
+import * as testHelpers from './test-helpers';
+import * as testIds from './test-ids';
+
+export { stats, testHelpers, testIds };
diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts
index 1642ca5580..dac96e4cef 100644
--- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts
+++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts
@@ -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,
+ },
]);
});
});
diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts
index 2899445b2a..8352db6754 100644
--- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts
+++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts
@@ -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;
diff --git a/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.test.ts b/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.test.ts
index 49fb169bf8..7bedee6dea 100644
--- a/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.test.ts
+++ b/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.test.ts
@@ -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',
diff --git a/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts b/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts
index 0b052a499d..74a1e09010 100644
--- a/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts
+++ b/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts
@@ -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('&'),
diff --git a/plugins/search/src/components/SearchBar/SearchBar.tsx b/plugins/search/src/components/SearchBar/SearchBar.tsx
index a1db818488..bdff003b7a 100644
--- a/plugins/search/src/components/SearchBar/SearchBar.tsx
+++ b/plugins/search/src/components/SearchBar/SearchBar.tsx
@@ -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(term);
+ useEffect(() => {
+ setValue(prevValue => (prevValue !== term ? term : prevValue));
+ }, [term]);
+
useDebounce(() => setTerm(value), debounceTime, [value]);
const handleQuery = (e: ChangeEvent) => {
diff --git a/plugins/search/src/components/SearchContext/SearchContext.tsx b/plugins/search/src/components/SearchContext/SearchContext.tsx
index 619471d490..45a74f5355 100644
--- a/plugins/search/src/components/SearchContext/SearchContext.tsx
+++ b/plugins/search/src/components/SearchContext/SearchContext.tsx
@@ -54,7 +54,7 @@ export const SearchContextProvider = ({
term: '',
pageCursor: '',
filters: {},
- types: ['*'],
+ types: [],
},
children,
}: PropsWithChildren<{ initialState?: SettableSearchContext }>) => {
diff --git a/plugins/search/src/components/SearchPage/SearchPage.test.tsx b/plugins/search/src/components/SearchPage/SearchPage.test.tsx
index 4c1ae38005..c387d57b60 100644
--- a/plugins/search/src/components/SearchPage/SearchPage.test.tsx
+++ b/plugins/search/src/components/SearchPage/SearchPage.test.tsx
@@ -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();
- // 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',
diff --git a/plugins/search/src/components/SearchPage/SearchPage.tsx b/plugins/search/src/components/SearchPage/SearchPage.tsx
index 8a85879a48..fb18833da6 100644
--- a/plugins/search/src/components/SearchPage/SearchPage.tsx
+++ b/plugins/search/src/components/SearchPage/SearchPage.tsx
@@ -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 (
-
+
{outlet || }