diff --git a/.changeset/fifty-berries-learn.md b/.changeset/fifty-berries-learn.md new file mode 100644 index 0000000000..647246a482 --- /dev/null +++ b/.changeset/fifty-berries-learn.md @@ -0,0 +1,37 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Added new column `Label` to `CatalogTable.columns`, this new column allows you make use of labels from metadata. +For example: category and visibility are type of labels associated with API entity illustrated below. + +YAML code snippet for API entity + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: sample-api + description: API for sample + links: + - url: http://localhost:8080/swagger-ui.html + title: Swagger UI + tags: + - http + labels: + category: legacy + visibility: protected +``` + +Consumers can customise columns to include label column and show in api-docs list + +```typescript +const columns = [ + CatalogTable.columns.createNameColumn({ defaultKind: 'API' }), + CatalogTable.columns.createLabelColumn('category', { title: 'Category' }), + CatalogTable.columns.createLabelColumn('visibility', { + title: 'Visibility', + defaultValue: 'public', + }), +]; +``` diff --git a/.github/workflows/sync_release-manifest.yml b/.github/workflows/sync_release-manifest.yml index 8cf6991aaf..c6d00ecd28 100644 --- a/.github/workflows/sync_release-manifest.yml +++ b/.github/workflows/sync_release-manifest.yml @@ -53,6 +53,10 @@ jobs: github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} # TODO(Rugvip): Remove the create-app dispatch once we've been on the release version for a while script: | + const releaseVersion = require('./backstage/package.json').version; + if(releaseVersion.includes('next')) { + return; + } console.log('Dispatching upgrade helper sync'); await github.rest.actions.createWorkflowDispatch({ owner: 'backstage', @@ -61,6 +65,6 @@ jobs: ref: 'master', inputs: { version: require('./backstage/packages/create-app/package.json').version, - releaseVersion: require('./backstage/package.json').version + releaseVersion }, }); diff --git a/docs/integrations/github/discovery.md b/docs/integrations/github/discovery.md index 6966753d68..6acb87ac37 100644 --- a/docs/integrations/github/discovery.md +++ b/docs/integrations/github/discovery.md @@ -96,6 +96,10 @@ catalog: topic: include: ['backstage-include'] # optional array of strings exclude: ['experiments'] # optional array of strings + enterpriseProviderId: + host: ghe.example.net + organization: 'backstage' # string + catalogPath: '/catalog-info.yaml' # string ``` This provider supports multiple organizations via unique provider IDs. @@ -125,6 +129,8 @@ This provider supports multiple organizations via unique provider IDs. - **organization**: Name of your organization account/workspace. If you want to add multiple organizations, you need to add one provider config each. +- **host** _(optional)_: + The hostname of your GitHub Enterprise instance. It must match a host defined in [integrations.github](locations.md). ## GitHub API Rate Limits diff --git a/docs/overview/support.md b/docs/overview/support.md index 5122a1c9ff..662a37c6ae 100644 --- a/docs/overview/support.md +++ b/docs/overview/support.md @@ -6,6 +6,7 @@ description: Support and Community Details and Links - [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the project. +- [Stack Overflow](https://stackoverflow.com/questions/tagged/backstage) - Browse or ask questions on Stack Overflow. - [Good First Issues](https://github.com/backstage/backstage/contribute) - Start here if you want to contribute. - [RFCs](https://github.com/backstage/backstage/labels/rfc) - Help shape the diff --git a/plugins/catalog-backend/src/integration.test.ts b/plugins/catalog-backend/src/integration.test.ts index 67b63428db..dcfe9a1489 100644 --- a/plugins/catalog-backend/src/integration.test.ts +++ b/plugins/catalog-backend/src/integration.test.ts @@ -34,7 +34,10 @@ import { ScmIntegrations } from '@backstage/integration'; import { DefaultCatalogRulesEnforcer } from './ingestion/CatalogRules'; import { Stitcher } from './stitching/Stitcher'; import { DefaultEntitiesCatalog } from './service/DefaultEntitiesCatalog'; -import { DefaultCatalogProcessingEngine } from './processing/DefaultCatalogProcessingEngine'; +import { + DefaultCatalogProcessingEngine, + ProgressTracker, +} from './processing/DefaultCatalogProcessingEngine'; import { createHash } from 'crypto'; import { DefaultRefreshService } from './service/DefaultRefreshService'; import { connectEntityProviders } from './processing/connectEntityProviders'; @@ -69,10 +72,6 @@ class TestProvider implements EntityProvider { } } -type ProgressTracker = NonNullable< - ConstructorParameters[7] ->; - class ProxyProgressTracker implements ProgressTracker { #inner: ProgressTracker; @@ -540,4 +539,139 @@ describe('Catalog Backend Integration', () => { await expect(harness.getOutputEntities()).resolves.toEqual(outputEntities); }); + + // NOTE(freben): This test documents existing behavior, but it would be more correct to mark the cycle as orphans + it('leaves behind orphaned cycles without orphan markers', async () => { + function mkEntity(name: string) { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name, + annotations: { + 'backstage.io/managed-by-location': 'url:.', + 'backstage.io/managed-by-origin-location': 'url:.', + }, + }, + }; + } + + const harness = await TestHarness.create({ + async processEntity( + entity: Entity, + location: LocationSpec, + emit: CatalogProcessorEmit, + ) { + if (entity.spec?.noEmit) { + return entity; + } + switch (entity.metadata.name) { + case 'a': + emit(processingResult.entity(location, mkEntity('b'))); + break; + case 'b': + emit(processingResult.entity(location, mkEntity('c'))); + break; + case 'c': + emit(processingResult.entity(location, mkEntity('d'))); + break; + case 'd': + emit(processingResult.entity(location, mkEntity('b'))); + break; + default: + } + return entity; + }, + }); + + await harness.setInputEntities([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'a', + annotations: { + 'backstage.io/managed-by-location': 'url:.', + 'backstage.io/managed-by-origin-location': 'url:.', + }, + }, + }, + ]); + + await expect(harness.getOutputEntities()).resolves.toEqual({}); + await expect(harness.process()).resolves.toEqual({}); + + await expect(harness.getOutputEntities()).resolves.toEqual({ + 'component:default/a': expect.objectContaining({ + metadata: expect.objectContaining({ name: 'a' }), + }), + 'component:default/b': expect.objectContaining({ + metadata: expect.objectContaining({ name: 'b' }), + }), + 'component:default/c': expect.objectContaining({ + metadata: expect.objectContaining({ name: 'c' }), + }), + 'component:default/d': expect.objectContaining({ + metadata: expect.objectContaining({ name: 'd' }), + }), + }); + // NOTE(freben): Avoid .toHaveProperty here, since it treats dots as path separators + expect( + (await harness.getOutputEntities())['component:default/b'].metadata + .annotations!['backstage.io/orphan'], + ).toBeUndefined(); + expect( + (await harness.getOutputEntities())['component:default/c'].metadata + .annotations!['backstage.io/orphan'], + ).toBeUndefined(); + expect( + (await harness.getOutputEntities())['component:default/d'].metadata + .annotations!['backstage.io/orphan'], + ).toBeUndefined(); + + await harness.setInputEntities([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'a', + annotations: { + 'backstage.io/managed-by-location': 'url:.', + 'backstage.io/managed-by-origin-location': 'url:.', + }, + }, + spec: { noEmit: true }, + }, + ]); + + await expect(harness.process()).resolves.toEqual({}); + + await expect(harness.getOutputEntities()).resolves.toEqual({ + 'component:default/a': expect.objectContaining({ + metadata: expect.objectContaining({ name: 'a' }), + }), + 'component:default/b': expect.objectContaining({ + metadata: expect.objectContaining({ name: 'b' }), + }), + 'component:default/c': expect.objectContaining({ + metadata: expect.objectContaining({ name: 'c' }), + }), + 'component:default/d': expect.objectContaining({ + metadata: expect.objectContaining({ name: 'd' }), + }), + }); + // TODO(freben): Ideally these should be orphaned now + expect( + (await harness.getOutputEntities())['component:default/b'].metadata + .annotations!['backstage.io/orphan'], + ).toBeUndefined(); + expect( + (await harness.getOutputEntities())['component:default/c'].metadata + .annotations!['backstage.io/orphan'], + ).toBeUndefined(); + expect( + (await harness.getOutputEntities())['component:default/d'].metadata + .annotations!['backstage.io/orphan'], + ).toBeUndefined(); + }); }); diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts index 4693ffc559..2cc0e2dc40 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts @@ -31,6 +31,8 @@ import { startTaskPipeline } from './TaskPipeline'; const CACHE_TTL = 5; +export type ProgressTracker = ReturnType; + export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { private stopFunc?: () => void; @@ -45,7 +47,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { unprocessedEntity: Entity; errors: Error[]; }) => Promise | void, - private readonly tracker = progressTracker(), + private readonly tracker: ProgressTracker = progressTracker(), ) {} async start() { diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 377b62a1e6..71006b53b5 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -148,6 +148,15 @@ export const CatalogTable: { } | undefined, ): TableColumn; + createLabelColumn( + key: string, + options?: + | { + title?: string | undefined; + defaultValue?: string | undefined; + } + | undefined, + ): TableColumn; }>; }; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index bd78cecaf0..bb2f302a37 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -331,4 +331,42 @@ describe('CatalogTable component', () => { expect(getByText('Should be rendered')).toBeInTheDocument(); }); + + it('should render the label column with customised title and value as specified', async () => { + const columns = [ + CatalogTable.columns.createNameColumn({ defaultKind: 'API' }), + CatalogTable.columns.createLabelColumn('category', { title: 'Category' }), + ]; + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { + name: 'APIWithLabel', + labels: { category: 'generic' }, + }, + }; + const expectedColumns = ['Name', 'Category', 'Actions']; + + const { getAllByRole, getByText } = await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + + const columnHeader = getAllByRole('button').filter( + c => c.tagName === 'SPAN', + ); + const columnHeaderLabels = columnHeader.map(c => c.textContent); + expect(columnHeaderLabels).toEqual(expectedColumns); + + const labelCellValue = getByText('generic'); + expect(labelCellValue).toBeInTheDocument(); + }); }); diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index 19d160c0e2..7f3cd5ef82 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -162,4 +162,35 @@ export const columnFactories = Object.freeze({ searchable: true, }; }, + createLabelColumn( + key: string, + options?: { title?: string; defaultValue?: string }, + ): TableColumn { + return { + title: options?.title || 'Label', + field: 'entity.metadata.labels', + cellStyle: { + padding: '0px 16px 0px 20px', + }, + render: ({ entity }: { entity: Entity }) => { + const labels: Record | undefined = + entity.metadata?.labels; + const specifiedLabelValue = + (labels && labels[key]) || options?.defaultValue; + return ( + <> + {specifiedLabelValue && ( + + )} + + ); + }, + width: 'auto', + }; + }, });