From c985173cdfccfe0276e090e83145d6e3acf365e6 Mon Sep 17 00:00:00 2001 From: Danylo Hotvianskyi Date: Fri, 11 Jul 2025 17:40:41 +0200 Subject: [PATCH 01/25] feat(scafolder): scaffolder-backend-module-github implement github:issues:create Signed-off-by: Danylo Hotvianskyi --- .../actions/githubIssuesCreate.examples.ts | 58 ++++++ .../src/actions/githubIssuesCreate.test.ts | 144 +++++++++++++++ .../src/actions/githubIssuesCreate.ts | 166 ++++++++++++++++++ .../src/actions/index.ts | 1 + .../src/module.ts | 5 + 5 files changed, 374 insertions(+) create mode 100644 plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts create mode 100644 plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts create mode 100644 plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts new file mode 100644 index 0000000000..17f6c8078b --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts @@ -0,0 +1,58 @@ +import { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import * as yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'Create a simple issue', + example: yaml.stringify({ + steps: [ + { + action: 'github:issues:create', + name: 'Create issue', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + title: 'Bug report', + body: 'Found a bug that needs to be fixed', + }, + }, + ], + }), + }, + { + description: 'Create an issue with labels and assignees', + example: yaml.stringify({ + steps: [ + { + action: 'github:issues:create', + name: 'Create issue with metadata', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + title: 'Feature request', + body: 'This is a new feature request', + labels: ['enhancement', 'needs-review'], + assignees: ['octocat'], + milestone: 1, + }, + }, + ], + }), + }, + { + description: 'Create an issue with specific token', + example: yaml.stringify({ + steps: [ + { + action: 'github:issues:create', + name: 'Create issue with token', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + title: 'Documentation update', + body: 'Update the documentation for the new API', + labels: ['documentation'], + token: 'gph_YourGitHubToken', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts new file mode 100644 index 0000000000..440df36915 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts @@ -0,0 +1,144 @@ +import { createGithubIssuesCreateAction } from './githubIssuesCreate'; +import { + ScmIntegrations, + DefaultGithubCredentialsProvider, + GithubCredentialsProvider, +} from '@backstage/integration'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { getOctokitOptions } from '../util'; + +jest.mock('../util', () => { + return { + getOctokitOptions: jest.fn(), + }; +}); + +import { Octokit } from 'octokit'; + +const octokitMock = Octokit as unknown as jest.Mock; +const mockOctokit = { + rest: { + issues: { + create: jest.fn(), + }, + }, +}; +jest.mock('octokit', () => ({ + Octokit: jest.fn(), +})); + +describe('github:issues:create', () => { + const config = new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'tokenlols' }, + { host: 'ghe.github.com' }, + ], + }, + }); + + const getOctokitOptionsMock = getOctokitOptions as jest.Mock; + const integrations = ScmIntegrations.fromConfig(config); + let githubCredentialsProvider: GithubCredentialsProvider; + let action: TemplateAction; + + const mockContext = createMockActionContext({ + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + title: 'Test Issue', + body: 'This is a test issue', + labels: ['bug', 'test'], + assignees: ['octocat'], + milestone: 1, + }, + }); + + beforeEach(() => { + jest.resetAllMocks(); + octokitMock.mockImplementation(() => mockOctokit); + mockOctokit.rest.issues.create.mockResolvedValue({ + data: { + html_url: 'https://github.com/owner/repo/issues/1', + number: 1, + }, + }); + githubCredentialsProvider = + DefaultGithubCredentialsProvider.fromIntegrations(integrations); + action = createGithubIssuesCreateAction({ + integrations, + githubCredentialsProvider, + }); + }); + + it('should pass context logger to Octokit client', async () => { + await action.handler(mockContext); + + expect(octokitMock).toHaveBeenCalledWith( + expect.objectContaining({ log: mockContext.logger }), + ); + }); + + it('should call the githubApi for creating issue', async () => { + await action.handler(mockContext); + expect(mockOctokit.rest.issues.create).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + title: 'Test Issue', + body: 'This is a test issue', + labels: ['bug', 'test'], + assignees: ['octocat'], + milestone: 1, + }); + expect(getOctokitOptionsMock.mock.calls[0][0].token).toBeUndefined(); + }); + + it('should call the githubApi for creating issue with token', async () => { + await action.handler({ + ...mockContext, + input: { ...mockContext.input, token: 'gph_YourGitHubToken' }, + }); + expect(mockOctokit.rest.issues.create).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + title: 'Test Issue', + body: 'This is a test issue', + labels: ['bug', 'test'], + assignees: ['octocat'], + milestone: 1, + }); + expect(getOctokitOptionsMock.mock.calls[0][0].token).toEqual( + 'gph_YourGitHubToken', + ); + }); + + it('should output issue URL and number', async () => { + await action.handler(mockContext); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://github.com/owner/repo/issues/1', + ); + expect(mockContext.output).toHaveBeenCalledWith('issueNumber', 1); + }); + + it('should create issue with minimal input', async () => { + const minimalContext = createMockActionContext({ + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + title: 'Simple Issue', + }, + }); + + await action.handler(minimalContext); + expect(mockOctokit.rest.issues.create).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + title: 'Simple Issue', + body: undefined, + labels: undefined, + assignees: undefined, + milestone: undefined, + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts new file mode 100644 index 0000000000..757e4ac675 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts @@ -0,0 +1,166 @@ +import { + GithubCredentialsProvider, + ScmIntegrationRegistry, +} from '@backstage/integration'; +import { + createTemplateAction, + parseRepoUrl, +} from '@backstage/plugin-scaffolder-node'; +import { assertError, InputError } from '@backstage/errors'; +import { Octokit } from 'octokit'; +import { getOctokitOptions } from '../util'; +import { examples } from './githubIssuesCreate.examples'; + +/** + * Creates an issue on GitHub + * @public + */ +export function createGithubIssuesCreateAction(options: { + integrations: ScmIntegrationRegistry; + githubCredentialsProvider?: GithubCredentialsProvider; +}) { + const { integrations, githubCredentialsProvider } = options; + + return createTemplateAction({ + id: 'github:issues:create', + description: 'Creates an issue on GitHub.', + examples, + supportsDryRun: true, + schema: { + input: { + repoUrl: z => + z.string({ + description: + 'Accepts the format `github.com?repo=reponame&owner=owner` where `reponame` is the repository name and `owner` is an organization or username', + }), + title: z => + z.string({ + description: 'The title of the issue', + }), + body: z => + z + .string({ + description: 'The contents of the issue', + }) + .optional(), + assignees: z => + z + .array(z.string(), { + description: + 'Logins for Users to assign to this issue. NOTE: Only users with push access can set assignees for new issues. Assignees are silently dropped otherwise.', + }) + .optional(), + milestone: z => + z + .union([z.string(), z.number()], { + description: + 'The number of the milestone to associate this issue with. NOTE: Only users with push access can set the milestone for new issues. The milestone is silently dropped otherwise.', + }) + .optional(), + labels: z => + z + .array(z.string(), { + description: + 'Labels to associate with this issue. NOTE: Only users with push access can set labels for new issues. Labels are silently dropped otherwise.', + }) + .optional(), + token: z => + z + .string({ + description: + 'The `GITHUB_TOKEN` to use for authorization to GitHub', + }) + .optional(), + }, + output: { + issueUrl: z => + z.string({ + description: 'The URL of the created issue', + }), + issueNumber: z => + z.number({ + description: 'The number of the created issue', + }), + }, + }, + async handler(ctx) { + const { + repoUrl, + title, + body, + assignees, + milestone, + labels, + token: providedToken, + } = ctx.input; + + const { host, owner, repo } = parseRepoUrl(repoUrl, integrations); + ctx.logger.info(`Creating issue "${title}" on repo ${repo}`); + + if (!owner) { + throw new InputError('Invalid repository owner provided in repoUrl'); + } + + const octokitOptions = await getOctokitOptions({ + integrations, + credentialsProvider: githubCredentialsProvider, + host, + owner, + repo, + token: providedToken, + }); + + const client = new Octokit({ + ...octokitOptions, + log: ctx.logger, + }); + + if (ctx.isDryRun) { + ctx.logger.info(`Performing dry run of creating issue "${title}"`); + ctx.output('issueUrl', `https://github.com/${owner}/${repo}/issues/42`); + ctx.output('issueNumber', 42); + ctx.logger.info(`Dry run complete`); + return; + } + + try { + const issue = await ctx.checkpoint({ + key: `github.issues.create.${owner}.${repo}.${title}`, + fn: async () => { + const response = await client.rest.issues.create({ + owner, + repo, + title, + body, + assignees, + milestone, + labels, + }); + + return { + html_url: response.data.html_url, + number: response.data.number, + }; + }, + }); + + if (!issue) { + throw new Error('Failed to create issue'); + } + + ctx.output('issueUrl', issue.html_url); + ctx.output('issueNumber', issue.number); + + ctx.logger.info( + `Successfully created issue #${issue.number}: ${issue.html_url}`, + ); + } catch (e) { + assertError(e); + ctx.logger.warn( + `Failed: creating issue '${title}' on repo: '${repo}', ${e.message}`, + ); + throw e; + } + }, + }); +} diff --git a/plugins/scaffolder-backend-module-github/src/actions/index.ts b/plugins/scaffolder-backend-module-github/src/actions/index.ts index 7cf4109e6b..21732d7908 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/index.ts @@ -16,6 +16,7 @@ export { createGithubActionsDispatchAction } from './githubActionsDispatch'; export { createGithubIssuesLabelAction } from './githubIssuesLabel'; +export { createGithubIssuesCreateAction } from './githubIssuesCreate'; export { createGithubRepoCreateAction } from './githubRepoCreate'; export { createGithubRepoPushAction } from './githubRepoPush'; export { createGithubWebhookAction } from './githubWebhook'; diff --git a/plugins/scaffolder-backend-module-github/src/module.ts b/plugins/scaffolder-backend-module-github/src/module.ts index 5812a22560..67bda58fc4 100644 --- a/plugins/scaffolder-backend-module-github/src/module.ts +++ b/plugins/scaffolder-backend-module-github/src/module.ts @@ -27,6 +27,7 @@ import { createGithubDeployKeyAction, createGithubEnvironmentAction, createGithubIssuesLabelAction, + createGithubIssuesCreateAction, createGithubRepoCreateAction, createGithubRepoPushAction, createGithubWebhookAction, @@ -82,6 +83,10 @@ export const githubModule = createBackendModule({ integrations, githubCredentialsProvider, }), + createGithubIssuesCreateAction({ + integrations, + githubCredentialsProvider, + }), createGithubRepoCreateAction({ integrations, githubCredentialsProvider, From f0f06b43542dec7533ae5c4d9cdb7b9a654139bc Mon Sep 17 00:00:00 2001 From: Danylo Hotvianskyi Date: Fri, 11 Jul 2025 18:12:07 +0200 Subject: [PATCH 02/25] add changeset Signed-off-by: Danylo Hotvianskyi --- .changeset/stupid-pans-flash.md | 35 +++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .changeset/stupid-pans-flash.md diff --git a/.changeset/stupid-pans-flash.md b/.changeset/stupid-pans-flash.md new file mode 100644 index 0000000000..bc857a243a --- /dev/null +++ b/.changeset/stupid-pans-flash.md @@ -0,0 +1,35 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': minor +--- + +Adding a new scaffolder action `github:issues:create` following the reference of `github:issues:label` with dryRun testing possibility + +It can be used like this + +``` + steps: + - id: create-simple-issue + name: Create Simple Issue + action: github:issues:create + input: + repoUrl: ${{ parameters.repoUrl }} + title: "[${{ parameters.projectName }}] Simple Bug Report" + body: | + ## Bug Description + This is a simple bug report created by the scaffolder template. + + ### Steps to Reproduce + 1. Run the application + 2. Navigate to the main page + 3. Click on the problematic button + + ### Expected Behavior + The button should work correctly. + + ### Actual Behavior + The button does not respond to clicks. + output: + links: + - title: Simple Issue + url: ${{ steps['create-simple-issue'].output.issueUrl }} +``` From 16ee5911ebc5d3051b25652695f241e99df12d89 Mon Sep 17 00:00:00 2001 From: Danylo Hotvianskyi Date: Fri, 11 Jul 2025 18:27:29 +0200 Subject: [PATCH 03/25] Add notices Signed-off-by: Danylo Hotvianskyi --- .../actions/githubIssuesCreate.examples.ts | 15 ++++++++++++++ .../src/actions/githubIssuesCreate.test.ts | 16 +++++++++++++++ .../src/actions/githubIssuesCreate.ts | 20 +++++++++++++++++-- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts index 17f6c8078b..7d767f0337 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts @@ -1,3 +1,18 @@ +/* + * Copyright 2023 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 { TemplateExample } from '@backstage/plugin-scaffolder-node'; import * as yaml from 'yaml'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts index 440df36915..474acf14d7 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts @@ -1,3 +1,19 @@ +/* + * 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 { createGithubIssuesCreateAction } from './githubIssuesCreate'; import { ScmIntegrations, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts index 757e4ac675..5c5703e20d 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts @@ -1,3 +1,19 @@ +/* + * 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 { GithubCredentialsProvider, ScmIntegrationRegistry, @@ -109,7 +125,7 @@ export function createGithubIssuesCreateAction(options: { repo, token: providedToken, }); - + const client = new Octokit({ ...octokitOptions, log: ctx.logger, @@ -157,7 +173,7 @@ export function createGithubIssuesCreateAction(options: { } catch (e) { assertError(e); ctx.logger.warn( - `Failed: creating issue '${title}' on repo: '${repo}', ${e.message}`, + `Failed: creating issue '${title}' on repo: '${repo}', ${e.message}`, ); throw e; } From d5f4dd1c9078a0903e8578649e30ad6024ddf629 Mon Sep 17 00:00:00 2001 From: Danylo Hotvianskyi Date: Fri, 11 Jul 2025 18:55:13 +0200 Subject: [PATCH 04/25] Changes in public API Signed-off-by: Danylo Hotvianskyi --- .../report.api.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 2941be7702..a2dcc3ec4e 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -136,6 +136,27 @@ export function createGithubEnvironmentAction(options: { 'v2' >; +// @public +export function createGithubIssuesCreateAction(options: { + integrations: ScmIntegrationRegistry; + githubCredentialsProvider?: GithubCredentialsProvider; +}): TemplateAction< + { + repoUrl: string; + title: string; + body?: string | undefined; + assignees?: string[] | undefined; + milestone?: string | number | undefined; + labels?: string[] | undefined; + token?: string | undefined; + }, + { + issueUrl: string; + issueNumber: number; + }, + 'v2' +>; + // @public export function createGithubIssuesLabelAction(options: { integrations: ScmIntegrationRegistry; From 5ae6d9dd4bd709a44bc61bcc326585ccfff27ab2 Mon Sep 17 00:00:00 2001 From: JounQin Date: Tue, 29 Jul 2025 17:33:05 +0800 Subject: [PATCH 05/25] feat(core-app-api): support no en languages Signed-off-by: JounQin --- .changeset/tasty-yaks-kiss.md | 5 +++++ .../AppLanguageApi/AppLanguageSelector.test.ts | 17 ++++++++++++++++- .../AppLanguageApi/AppLanguageSelector.ts | 4 +--- 3 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 .changeset/tasty-yaks-kiss.md diff --git a/.changeset/tasty-yaks-kiss.md b/.changeset/tasty-yaks-kiss.md new file mode 100644 index 0000000000..125b77a870 --- /dev/null +++ b/.changeset/tasty-yaks-kiss.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +feat: support no en languages diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts index 702d5e642a..eeeb11634d 100644 --- a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.test.ts @@ -144,11 +144,26 @@ describe('AppLanguageSelector', () => { AppLanguageSelector.createWithStorage({ availableLanguages: ['de'], }), - ).toThrow("Supported languages must include 'en'"); + ).toThrow( + "Initial language must be one of the supported languages, got 'en'", + ); const selector = AppLanguageSelector.createWithStorage(baseOptions); expect(() => selector.setLanguage('sv')).toThrow( "Failed to change language to 'sv', available languages are 'en', 'de'", ); }); + + it('should support no en languages', () => { + const selector = AppLanguageSelector.createWithStorage({ + availableLanguages: ['de'], + defaultLanguage: 'de', + }); + + expect(selector.getLanguage()).toEqual({ language: 'de' }); + + expect(() => selector.setLanguage('en')).toThrow( + "Failed to change language to 'en', available languages are 'de'", + ); + }); }); diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts index 93f3d6517c..04dcfe9ebb 100644 --- a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts @@ -44,11 +44,9 @@ export class AppLanguageSelector implements AppLanguageApi { )}'`, ); } - if (!languages.includes(DEFAULT_LANGUAGE)) { - throw new Error(`Supported languages must include '${DEFAULT_LANGUAGE}'`); - } const initialLanguage = options?.defaultLanguage ?? DEFAULT_LANGUAGE; + if (!languages.includes(initialLanguage)) { throw new Error( `Initial language must be one of the supported languages, got '${initialLanguage}'`, From 4316c11c3d105b0e8f2d0066e967efbea40769d4 Mon Sep 17 00:00:00 2001 From: mario ma Date: Wed, 30 Jul 2025 17:55:39 +0800 Subject: [PATCH 06/25] feat: Catalog table columns support i18n Signed-off-by: mario ma --- .changeset/lovely-frogs-share.md | 6 +++ plugins/catalog-react/report-alpha.api.md | 46 ++++++++++++++++++ plugins/catalog-react/report.api.md | 4 +- plugins/catalog-react/src/alpha/index.ts | 1 + .../EntityTable/TitleColumn.test.tsx | 25 ++++++++++ .../components/EntityTable/TitleColumn.tsx | 47 +++++++++++++++++++ .../src/components/EntityTable/columns.tsx | 19 ++++---- plugins/catalog-react/src/translation.ts | 14 ++++++ .../src/components/CatalogTable/columns.tsx | 25 +++++----- 9 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 .changeset/lovely-frogs-share.md create mode 100644 plugins/catalog-react/src/components/EntityTable/TitleColumn.test.tsx create mode 100644 plugins/catalog-react/src/components/EntityTable/TitleColumn.tsx diff --git a/.changeset/lovely-frogs-share.md b/.changeset/lovely-frogs-share.md new file mode 100644 index 0000000000..5c630f301e --- /dev/null +++ b/.changeset/lovely-frogs-share.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-catalog': patch +--- + +Catalog table columns support i18n diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 1db7a8e5e4..ebdf9f0e79 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -86,6 +86,18 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'userListPicker.personalFilter.title': 'Personal'; readonly 'userListPicker.personalFilter.ownedLabel': 'Owned'; readonly 'userListPicker.personalFilter.starredLabel': 'Starred'; + readonly 'entityTableColumnTitle.name': 'Name'; + readonly 'entityTableColumnTitle.type': 'Type'; + readonly 'entityTableColumnTitle.label': 'Label'; + readonly 'entityTableColumnTitle.title': 'Title'; + readonly 'entityTableColumnTitle.description': 'Description'; + readonly 'entityTableColumnTitle.domain': 'Domain'; + readonly 'entityTableColumnTitle.namespace': 'Namespace'; + readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; + readonly 'entityTableColumnTitle.owner': 'Owner'; + readonly 'entityTableColumnTitle.system': 'System'; + readonly 'entityTableColumnTitle.targets': 'Targets'; + readonly 'entityTableColumnTitle.tags': 'Tags'; } >; @@ -517,6 +529,40 @@ export type EntityPredicateValue = $contains: EntityPredicateExpression; }; +// @alpha (undocumented) +export const EntityTableColumnTitle: ({ + translationKey, +}: EntityTableColumnTitleProps) => + | 'Domain' + | 'System' + | 'Name' + | 'Description' + | 'Lifecycle' + | 'Namespace' + | 'Owner' + | 'Tags' + | 'Type' + | 'Targets' + | 'Title' + | 'Label'; + +// @alpha (undocumented) +export type EntityTableColumnTitleProps = { + translationKey: + | 'name' + | 'system' + | 'owner' + | 'type' + | 'lifecycle' + | 'namespace' + | 'description' + | 'tags' + | 'targets' + | 'title' + | 'label' + | 'domain'; +}; + // @alpha export function isOwnerOf(owner: Entity, entity: Entity): boolean; diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index bfebb2b13a..df32c51416 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -171,7 +171,7 @@ export const columnFactories: Readonly<{ defaultKind?: string; }): TableColumn; createEntityRelationColumn(options: { - title: string; + title: string | JSX.Element; relation: string; defaultKind?: string; filter?: { @@ -575,7 +575,7 @@ export const EntityTable: { defaultKind?: string; }): TableColumn; createEntityRelationColumn(options: { - title: string; + title: string | JSX.Element; relation: string; defaultKind?: string; filter?: { diff --git a/plugins/catalog-react/src/alpha/index.ts b/plugins/catalog-react/src/alpha/index.ts index 4ff4dbf0dd..f5909c9aa2 100644 --- a/plugins/catalog-react/src/alpha/index.ts +++ b/plugins/catalog-react/src/alpha/index.ts @@ -20,3 +20,4 @@ export * from './predicates'; export { catalogReactTranslationRef } from '../translation'; export { isOwnerOf } from '../utils/isOwnerOf'; export { useEntityPermission } from '../hooks/useEntityPermission'; +export * from '../components/EntityTable/TitleColumn'; diff --git a/plugins/catalog-react/src/components/EntityTable/TitleColumn.test.tsx b/plugins/catalog-react/src/components/EntityTable/TitleColumn.test.tsx new file mode 100644 index 0000000000..e08741d274 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/TitleColumn.test.tsx @@ -0,0 +1,25 @@ +/* + * Copyright 2025 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 { screen } from '@testing-library/react'; +import { EntityTableColumnTitle } from './TitleColumn'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('', () => { + it('renders the translated title for the given key', async () => { + await renderInTestApp(); + expect(screen.getByText('Name')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityTable/TitleColumn.tsx b/plugins/catalog-react/src/components/EntityTable/TitleColumn.tsx new file mode 100644 index 0000000000..36bce84882 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/TitleColumn.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2025 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 { useTranslationRef } from '@backstage/frontend-plugin-api'; +import { catalogReactTranslationRef } from '../../translation'; + +/** + * @alpha + */ +export type EntityTableColumnTitleProps = { + translationKey: + | 'name' + | 'system' + | 'owner' + | 'type' + | 'lifecycle' + | 'namespace' + | 'description' + | 'tags' + | 'targets' + | 'title' + | 'label' + | 'domain'; +}; + +/** + * @alpha + */ +export const EntityTableColumnTitle = ({ + translationKey, +}: EntityTableColumnTitleProps) => { + const { t } = useTranslationRef(catalogReactTranslationRef); + return t(`entityTableColumnTitle.${translationKey}`); +}; diff --git a/plugins/catalog-react/src/components/EntityTable/columns.tsx b/plugins/catalog-react/src/components/EntityTable/columns.tsx index 00e1e5ad43..94b1141057 100644 --- a/plugins/catalog-react/src/components/EntityTable/columns.tsx +++ b/plugins/catalog-react/src/components/EntityTable/columns.tsx @@ -27,8 +27,7 @@ import { EntityRefLinks, humanizeEntityRef, } from '../EntityRefLink'; - -// TODO: column title support i18n +import { EntityTableColumnTitle } from './TitleColumn'; /** @public */ export const columnFactories = Object.freeze({ @@ -46,7 +45,7 @@ export const columnFactories = Object.freeze({ } return { - title: 'Name', + title: , highlight: true, customFilterAndSearch(filter, entity) { // TODO: We could implement this more efficiently, like searching over @@ -72,7 +71,7 @@ export const columnFactories = Object.freeze({ }; }, createEntityRelationColumn(options: { - title: string; + title: string | JSX.Element; relation: string; defaultKind?: string; filter?: { kind: string }; @@ -109,14 +108,14 @@ export const columnFactories = Object.freeze({ }, createOwnerColumn(): TableColumn { return this.createEntityRelationColumn({ - title: 'Owner', + title: , relation: RELATION_OWNED_BY, defaultKind: 'group', }); }, createDomainColumn(): TableColumn { return this.createEntityRelationColumn({ - title: 'Domain', + title: , relation: RELATION_PART_OF, defaultKind: 'domain', filter: { @@ -126,7 +125,7 @@ export const columnFactories = Object.freeze({ }, createSystemColumn(): TableColumn { return this.createEntityRelationColumn({ - title: 'System', + title: , relation: RELATION_PART_OF, defaultKind: 'system', filter: { @@ -136,7 +135,7 @@ export const columnFactories = Object.freeze({ }, createMetadataDescriptionColumn(): TableColumn { return { - title: 'Description', + title: , field: 'metadata.description', render: entity => ( (): TableColumn { return { - title: 'Lifecycle', + title: , field: 'spec.lifecycle', }; }, createSpecTypeColumn(): TableColumn { return { - title: 'Type', + title: , field: 'spec.type', }; }, diff --git a/plugins/catalog-react/src/translation.ts b/plugins/catalog-react/src/translation.ts index 7f9661380f..6e831886cd 100644 --- a/plugins/catalog-react/src/translation.ts +++ b/plugins/catalog-react/src/translation.ts @@ -124,5 +124,19 @@ export const catalogReactTranslationRef = createTranslationRef({ }, orgFilterAllLabel: 'All', }, + entityTableColumnTitle: { + name: 'Name', + system: 'System', + owner: 'Owner', + type: 'Type', + lifecycle: 'Lifecycle', + namespace: 'Namespace', + description: 'Description', + tags: 'Tags', + targets: 'Targets', + title: 'Title', + label: 'Label', + domain: 'Domain', + }, }, }); diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index 05674f3e1b..65094bc568 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -23,6 +23,7 @@ import { CatalogTableRow } from './types'; import { OverflowTooltip, TableColumn } from '@backstage/core-components'; import { Entity } from '@backstage/catalog-model'; import { JsonArray } from '@backstage/types'; +import { EntityTableColumnTitle } from '@backstage/plugin-catalog-react/alpha'; // The columnFactories symbol is not directly exported, but through the // CatalogTable.columns field. @@ -41,7 +42,7 @@ export const columnFactories = Object.freeze({ } return { - title: 'Name', + title: , field: 'resolved.entityRef', highlight: true, customSort({ entity: entity1 }, { entity: entity2 }) { @@ -59,7 +60,7 @@ export const columnFactories = Object.freeze({ }, createSystemColumn(): TableColumn { return { - title: 'System', + title: , field: 'resolved.partOfSystemRelationTitle', customFilterAndSearch: (query, row) => { if (!row.resolved.partOfSystemRelations) { @@ -83,7 +84,7 @@ export const columnFactories = Object.freeze({ }, createOwnerColumn(): TableColumn { return { - title: 'Owner', + title: , field: 'resolved.ownedByRelationsTitle', render: ({ resolved }) => ( { return { - title: 'Targets', + title: , field: 'entity.spec.targets', customFilterAndSearch: (query, row) => { let targets: JsonArray = []; @@ -132,7 +133,7 @@ export const columnFactories = Object.freeze({ } = { hidden: false }, ): TableColumn { return { - title: 'Type', + title: , field: 'entity.spec.type', hidden: options.hidden, width: 'auto', @@ -140,13 +141,13 @@ export const columnFactories = Object.freeze({ }, createSpecLifecycleColumn(): TableColumn { return { - title: 'Lifecycle', + title: , field: 'entity.spec.lifecycle', }; }, createMetadataDescriptionColumn(): TableColumn { return { - title: 'Description', + title: , field: 'entity.metadata.description', render: ({ entity }) => ( { return { - title: 'Tags', + title: , field: 'entity.metadata.tags', cellStyle: { padding: '0px 16px 0px 20px', @@ -185,7 +186,7 @@ export const columnFactories = Object.freeze({ hidden?: boolean; }): TableColumn { return { - title: 'Title', + title: , field: 'entity.metadata.title', hidden: options?.hidden, searchable: true, @@ -202,7 +203,9 @@ export const columnFactories = Object.freeze({ } return { - title: options?.title || 'Label', + title: options?.title || ( + + ), field: 'entity.metadata.labels', cellStyle: { padding: '0px 16px 0px 20px', @@ -235,7 +238,7 @@ export const columnFactories = Object.freeze({ }, createNamespaceColumn(): TableColumn { return { - title: 'Namespace', + title: , field: 'entity.metadata.namespace', width: 'auto', }; From 88225a8cf3ede83dc9b932ae560bb9cf111e3707 Mon Sep 17 00:00:00 2001 From: mario ma Date: Fri, 1 Aug 2025 15:27:21 +0800 Subject: [PATCH 07/25] fix: api report Signed-off-by: mario ma --- plugins/catalog-react/report-alpha.api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index ebdf9f0e79..132cf5eac7 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -533,17 +533,17 @@ export type EntityPredicateValue = export const EntityTableColumnTitle: ({ translationKey, }: EntityTableColumnTitleProps) => + | 'Title' | 'Domain' | 'System' - | 'Name' - | 'Description' | 'Lifecycle' | 'Namespace' | 'Owner' | 'Tags' | 'Type' + | 'Name' + | 'Description' | 'Targets' - | 'Title' | 'Label'; // @alpha (undocumented) From ea80e76e7c997ecacd57b551ce01fbc9b390e6cb Mon Sep 17 00:00:00 2001 From: Patrick McDonnell Date: Fri, 1 Aug 2025 13:35:47 +0200 Subject: [PATCH 08/25] perf: use GitLab API simple=true parameter when not filtering forks Reduces response size by conditionally using simple=true in GitLab list projects API calls. Only applied when skipForkedRepos is false since fork detection requires the forked_from_project field excluded by simple. Signed-off-by: Patrick McDonnell --- .changeset/cuddly-lemons-cheat.md | 5 + .../src/GitLabDiscoveryProcessor.test.ts | 107 +++++++++++++++ .../src/GitLabDiscoveryProcessor.ts | 5 + .../src/lib/client.test.ts | 49 +++++++ .../src/lib/client.ts | 1 + .../GitlabDiscoveryEntityProvider.test.ts | 127 ++++++++++++++++++ .../GitlabDiscoveryEntityProvider.ts | 5 + 7 files changed, 299 insertions(+) create mode 100644 .changeset/cuddly-lemons-cheat.md diff --git a/.changeset/cuddly-lemons-cheat.md b/.changeset/cuddly-lemons-cheat.md new file mode 100644 index 0000000000..aa4ee786ff --- /dev/null +++ b/.changeset/cuddly-lemons-cheat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-gitlab': patch +--- + +When possible, requests a more limited set of results from the Gitlab projects API, which can reduce the amount of network traffic required to sync with Gitlab. diff --git a/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.test.ts b/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.test.ts index d75d0e224e..21cbd24232 100644 --- a/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.test.ts +++ b/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.test.ts @@ -59,6 +59,7 @@ function setupFakeServer( page: number; include_subgroups: boolean; archived: boolean; + simple?: boolean; }) => { data: GitLabProject[]; nextPage?: number; @@ -78,10 +79,12 @@ function setupFakeServer( const page = req.url.searchParams.get('page'); const include_subgroups = req.url.searchParams.get('include_subgroups'); const archived = req.url.searchParams.get('archived'); + const simple = req.url.searchParams.get('simple'); const response = listProjectsCallback({ page: parseInt(page!, 10), include_subgroups: include_subgroups === 'true', archived: archived === 'true', + simple: simple === 'true', }); // Filter the fake results based on the `last_activity_after` parameter @@ -471,6 +474,110 @@ describe('GitlabDiscoveryProcessor', () => { }); expect(result2).toHaveLength(1); }); + + it('sets simple=true when skipForkedRepos is false', async () => { + const processor = getProcessor({ + options: { skipForkedRepos: false }, + }); + setupFakeServer( + PROJECTS_URL, + request => { + return { + data: [ + { + id: 1, + archived: false, + default_branch: 'main', + last_activity_at: '2021-08-05T11:03:05.774Z', + web_url: 'https://gitlab.fake/1', + path_with_namespace: '1', + }, + ], + }; + }, + request => { + // Verify that simple=true is set in the request + expect(request.url.searchParams.get('simple')).toBe('true'); + }, + ); + + const result: any[] = []; + await processor.readLocation(PROJECT_LOCATION, false, e => { + result.push(e); + }); + expect(result).toHaveLength(1); + }); + + it('does not set simple when skipForkedRepos is true', async () => { + const processor = getProcessor({ + options: { skipForkedRepos: true }, + }); + setupFakeServer( + PROJECTS_URL, + request => { + return { + data: [ + { + id: 1, + archived: false, + default_branch: 'main', + last_activity_at: '2021-08-05T11:03:05.774Z', + web_url: 'https://gitlab.fake/1', + path_with_namespace: '1', + // Include forked_from_project to test fork filtering + forked_from_project: { + id: 100, + name: 'original-project', + }, + }, + ], + }; + }, + request => { + // Verify that simple parameter is not set + expect(request.url.searchParams.get('simple')).toBeNull(); + }, + ); + + const result: any[] = []; + await processor.readLocation(PROJECT_LOCATION, false, e => { + result.push(e); + }); + // Should be empty because forked repo is skipped + expect(result).toHaveLength(0); + }); + + it('sets default parameters correctly (archived=false, simple=true)', async () => { + const processor = getProcessor(); // Uses defaults: skipForkedRepos=false, includeArchivedRepos=false + setupFakeServer( + PROJECTS_URL, + request => { + return { + data: [ + { + id: 1, + archived: false, + default_branch: 'main', + last_activity_at: '2021-08-05T11:03:05.774Z', + web_url: 'https://gitlab.fake/1', + path_with_namespace: '1', + }, + ], + }; + }, + request => { + // Verify default parameters: archived=false and simple=true + expect(request.url.searchParams.get('archived')).toBe('false'); + expect(request.url.searchParams.get('simple')).toBe('true'); + }, + ); + + const result: any[] = []; + await processor.readLocation(PROJECT_LOCATION, false, e => { + result.push(e); + }); + expect(result).toHaveLength(1); + }); }); describe('handles failure', () => { diff --git a/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.ts b/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.ts index ab31a44bbc..126bb07f03 100644 --- a/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.ts +++ b/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.ts @@ -115,6 +115,11 @@ export class GitLabDiscoveryProcessor implements CatalogProcessor { // that the options doesn't include the key so that the API doesn't receive an empty query parameter. ...(lastActivity && { last_activity_after: lastActivity }), ...(!this.includeArchivedRepos && { archived: false }), + // Only use simple=true when we don't need to skip forked repos. + // The simple=true parameter reduces response size by returning fewer fields, + // but it excludes the 'forked_from_project' field which is required for fork detection. + // Therefore, we can only optimize with simple=true when skipForkedRepos is false. + ...(!this.skipForkedRepos && { simple: true }), }; const projects = paginated(options => client.listProjects(options), opts); diff --git a/plugins/catalog-backend-module-gitlab/src/lib/client.test.ts b/plugins/catalog-backend-module-gitlab/src/lib/client.test.ts index daea71060f..9b3bac6399 100644 --- a/plugins/catalog-backend-module-gitlab/src/lib/client.test.ts +++ b/plugins/catalog-backend-module-gitlab/src/lib/client.test.ts @@ -187,6 +187,55 @@ describe('GitLabClient', () => { expect(allProjects).toHaveLength(mock.all_projects_response.length); }); + + it('should pass simple parameter to API when provided', async () => { + const client = new GitLabClient({ + config: readGitLabIntegrationConfig( + new ConfigReader(mock.config_self_managed), + ), + logger: mockServices.logger.mock(), + }); + + // Mock the pagedRequest method to verify parameters + const mockPagedRequest = jest.fn().mockResolvedValue({ + items: [], + nextPage: undefined, + }); + (client as any).pagedRequest = mockPagedRequest; + + await client.listProjects({ simple: true }); + + expect(mockPagedRequest).toHaveBeenCalledWith('/projects', { + simple: true, + }); + }); + + it('should pass simple parameter to group projects API when provided', async () => { + const client = new GitLabClient({ + config: readGitLabIntegrationConfig( + new ConfigReader(mock.config_self_managed), + ), + logger: mockServices.logger.mock(), + }); + + // Mock the pagedRequest method to verify parameters + const mockPagedRequest = jest.fn().mockResolvedValue({ + items: [], + nextPage: undefined, + }); + (client as any).pagedRequest = mockPagedRequest; + + await client.listProjects({ group: 'test-group', simple: true }); + + expect(mockPagedRequest).toHaveBeenCalledWith( + '/groups/test-group/projects', + { + group: 'test-group', + simple: true, + include_subgroups: true, + }, + ); + }); }); describe('listUsers', () => { diff --git a/plugins/catalog-backend-module-gitlab/src/lib/client.ts b/plugins/catalog-backend-module-gitlab/src/lib/client.ts index e58227839e..3178e2c0d1 100644 --- a/plugins/catalog-backend-module-gitlab/src/lib/client.ts +++ b/plugins/catalog-backend-module-gitlab/src/lib/client.ts @@ -43,6 +43,7 @@ interface ListProjectOptions extends CommonListOptions { group?: string; membership?: boolean; topics?: string; + simple?: boolean; } interface UserListOptions extends CommonListOptions { diff --git a/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.test.ts b/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.test.ts index dd7a4f0d6d..b03f2c4b5b 100644 --- a/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.test.ts +++ b/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.test.ts @@ -147,6 +147,21 @@ describe('GitlabDiscoveryEntityProvider - refresh', () => { 'GitlabDiscoveryEntityProvider:test-id', ); + // Mock the GitLabClient listProjects method to verify default parameters + const originalListProjects = (provider as any).gitLabClient.listProjects; + const mockListProjects = jest.fn().mockImplementation(async options => { + // Verify default parameters: archived=false and simple=true (since skipForkedRepos=false by default) + expect(options).toMatchObject({ + group: 'group1', + per_page: 50, + archived: false, + simple: true, // Should be set since skipForkedRepos defaults to false + }); + // Call the original method to maintain test behavior + return originalListProjects.call((provider as any).gitLabClient, options); + }); + (provider as any).gitLabClient.listProjects = mockListProjects; + await provider.connect(entityProviderConnection); const taskDef = schedule.getTasks()[0]; @@ -559,3 +574,115 @@ describe('GitlabDiscoveryEntityProvider - events', () => { }); // EventSupportChange: stop add tests >>> }); + +describe('GitlabDiscoveryEntityProvider - simple parameter', () => { + it('should pass simple=true when skipForkedRepos is false', async () => { + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'example.com', + apiBaseUrl: 'https://example.com/api/v4', + token: 'test-token', + }, + ], + }, + catalog: { + providers: { + gitlab: { + 'test-id': { + host: 'example.com', + group: 'test-group', + skipForkedRepos: false, + }, + }, + }, + }, + }); + + const schedule = new PersistingTaskRunner(); + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + const provider = GitlabDiscoveryEntityProvider.fromConfig(config, { + logger, + schedule, + })[0]; + + // Mock the GitLabClient listProjects method to verify parameters + const mockListProjects = jest.fn().mockResolvedValue({ + items: [], + nextPage: undefined, + }); + + (provider as any).gitLabClient.listProjects = mockListProjects; + + await provider.connect(entityProviderConnection); + await provider.refresh(logger); + + expect(mockListProjects).toHaveBeenCalledWith({ + group: 'test-group', + page: undefined, + per_page: 50, + archived: false, + simple: true, // Should be set when skipForkedRepos is false + }); + }); + + it('should not pass simple when skipForkedRepos is true', async () => { + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'example.com', + apiBaseUrl: 'https://example.com/api/v4', + token: 'test-token', + }, + ], + }, + catalog: { + providers: { + gitlab: { + 'test-id': { + host: 'example.com', + group: 'test-group', + skipForkedRepos: true, + }, + }, + }, + }, + }); + + const schedule = new PersistingTaskRunner(); + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + const provider = GitlabDiscoveryEntityProvider.fromConfig(config, { + logger, + schedule, + })[0]; + + // Mock the GitLabClient listProjects method to verify parameters + const mockListProjects = jest.fn().mockResolvedValue({ + items: [], + nextPage: undefined, + }); + + (provider as any).gitLabClient.listProjects = mockListProjects; + + await provider.connect(entityProviderConnection); + await provider.refresh(logger); + + expect(mockListProjects).toHaveBeenCalledWith({ + group: 'test-group', + page: undefined, + per_page: 50, + archived: false, + // simple should not be present when skipForkedRepos is true + }); + }); +}); diff --git a/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts b/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts index 8bf749c275..243ffd5287 100644 --- a/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts +++ b/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts @@ -216,6 +216,11 @@ export class GitlabDiscoveryEntityProvider implements EntityProvider { ...(!this.config.includeArchivedRepos && { archived: false }), ...(this.config.membership && { membership: true }), ...(this.config.topics && { topics: this.config.topics }), + // Only use simple=true when we don't need to skip forked repos. + // The simple=true parameter reduces response size by returning fewer fields, + // but it excludes the 'forked_from_project' field which is required for fork detection. + // Therefore, we can only optimize with simple=true when skipForkedRepos is false. + ...(!this.config.skipForkedRepos && { simple: true }), }, ); From f8db72be543bf81956822b2d0f19101aae5f647b Mon Sep 17 00:00:00 2001 From: Patrick McDonnell Date: Fri, 1 Aug 2025 13:46:50 +0200 Subject: [PATCH 09/25] fix linter error Signed-off-by: Patrick McDonnell --- .../src/GitLabDiscoveryProcessor.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.test.ts b/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.test.ts index 21cbd24232..774da46553 100644 --- a/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.test.ts +++ b/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.test.ts @@ -481,7 +481,7 @@ describe('GitlabDiscoveryProcessor', () => { }); setupFakeServer( PROJECTS_URL, - request => { + _ => { return { data: [ { @@ -514,7 +514,7 @@ describe('GitlabDiscoveryProcessor', () => { }); setupFakeServer( PROJECTS_URL, - request => { + _ => { return { data: [ { @@ -551,7 +551,7 @@ describe('GitlabDiscoveryProcessor', () => { const processor = getProcessor(); // Uses defaults: skipForkedRepos=false, includeArchivedRepos=false setupFakeServer( PROJECTS_URL, - request => { + _ => { return { data: [ { From ddf1de03f85b98be124cf7ed95e50fc5fbcfba8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 25 Aug 2025 11:10:14 +0200 Subject: [PATCH 10/25] Update .changeset/stupid-pans-flash.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/stupid-pans-flash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/stupid-pans-flash.md b/.changeset/stupid-pans-flash.md index bc857a243a..3e8c2333dd 100644 --- a/.changeset/stupid-pans-flash.md +++ b/.changeset/stupid-pans-flash.md @@ -2,7 +2,7 @@ '@backstage/plugin-scaffolder-backend-module-github': minor --- -Adding a new scaffolder action `github:issues:create` following the reference of `github:issues:label` with dryRun testing possibility +Adding a new scaffolder action `github:issues:create` following the reference of `github:issues:label` with `dryRun` testing possibility It can be used like this From 02f3d811604877ab6c5deaf704e9054746020d8e Mon Sep 17 00:00:00 2001 From: Praphull <1288395+praphull-purohit@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:28:41 +0200 Subject: [PATCH 11/25] Add block creations field to github branch protection scaffolder action Signed-off-by: Praphull <1288395+praphull-purohit@users.noreply.github.com> --- .../src/actions/gitHelpers.ts | 3 +++ .../src/actions/githubBranchProtection.ts | 3 +++ .../src/actions/inputProperties.ts | 9 +++++++++ 3 files changed, 15 insertions(+) diff --git a/plugins/scaffolder-backend-module-github/src/actions/gitHelpers.ts b/plugins/scaffolder-backend-module-github/src/actions/gitHelpers.ts index c720690ee6..d7ef3496dd 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/gitHelpers.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/gitHelpers.ts @@ -44,6 +44,7 @@ type BranchProtectionOptions = { dismissStaleReviews?: boolean; requiredCommitSigning?: boolean; requiredLinearHistory?: boolean; + blockCreations?: boolean; }; export const enableBranchProtectionOnDefaultRepoBranch = async ({ @@ -64,6 +65,7 @@ export const enableBranchProtectionOnDefaultRepoBranch = async ({ dismissStaleReviews = false, requiredCommitSigning = false, requiredLinearHistory = false, + blockCreations = false, }: BranchProtectionOptions): Promise => { const tryOnce = async () => { try { @@ -96,6 +98,7 @@ export const enableBranchProtectionOnDefaultRepoBranch = async ({ }, required_conversation_resolution: requiredConversationResolution, required_linear_history: requiredLinearHistory, + block_creations: blockCreations, }); if (requiredCommitSigning) { diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts index f82bfc6950..6ae0f67135 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts @@ -57,6 +57,7 @@ export function createGithubBranchProtectionAction(options: { requireLastPushApproval: inputProps.requireLastPushApproval, requiredCommitSigning: inputProps.requiredCommitSigning, requiredLinearHistory: inputProps.requiredLinearHistory, + blockCreations: inputProps.blockCreations, token: inputProps.token, }, }, @@ -76,6 +77,7 @@ export function createGithubBranchProtectionAction(options: { requireLastPushApproval = false, requiredCommitSigning = false, requiredLinearHistory = false, + blockCreations = false, token: providedToken, } = ctx.input; @@ -129,6 +131,7 @@ export function createGithubBranchProtectionAction(options: { dismissStaleReviews, requiredCommitSigning, requiredLinearHistory, + blockCreations, }); }, }); diff --git a/plugins/scaffolder-backend-module-github/src/actions/inputProperties.ts b/plugins/scaffolder-backend-module-github/src/actions/inputProperties.ts index e1df40f85d..bfa232c45d 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/inputProperties.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/inputProperties.ts @@ -348,6 +348,14 @@ const requiredLinearHistory = (z: typeof zod) => }) .optional(); +const blockCreations = (z: typeof zod) => + z + .boolean({ + description: `Prevents creation of new branches during push, unless the push is initiated by a user, team, or app (defined in restrictions) which has the ability to push.`, + }) + .default(false) + .optional(); + const repoVariables = (z: typeof zod) => z .record(z.string(), { @@ -449,4 +457,5 @@ export { protectEnforceAdmins, bypassPullRequestAllowances, branch, + blockCreations, }; From 6393b78fe4115cda68b1f1ff5a6805acc4f26413 Mon Sep 17 00:00:00 2001 From: Praphull <1288395+praphull-purohit@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:38:25 +0200 Subject: [PATCH 12/25] Add changeset Signed-off-by: Praphull <1288395+praphull-purohit@users.noreply.github.com> --- .changeset/ripe-boxes-stand.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ripe-boxes-stand.md diff --git a/.changeset/ripe-boxes-stand.md b/.changeset/ripe-boxes-stand.md new file mode 100644 index 0000000000..e40dedbe90 --- /dev/null +++ b/.changeset/ripe-boxes-stand.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': minor +--- + +Add field block_creations in github branch protection scaffolder actions From f2be6858aef081fc37bc11dec20d110da6d97af3 Mon Sep 17 00:00:00 2001 From: Praphull <1288395+praphull-purohit@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:40:51 +0200 Subject: [PATCH 13/25] Update tests Signed-off-by: Praphull <1288395+praphull-purohit@users.noreply.github.com> --- .../src/actions/githubBranchProtection.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.test.ts index d20a02d460..98d296e176 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.test.ts @@ -105,6 +105,7 @@ describe('github:branch-protection:create', () => { }, required_conversation_resolution: false, required_linear_history: false, + block_creations: false, }); expect( mockOctokit.rest.repos.createCommitSignatureProtection, @@ -142,6 +143,7 @@ describe('github:branch-protection:create', () => { }, required_conversation_resolution: false, required_linear_history: false, + block_creations: false, }); expect( mockOctokit.rest.repos.createCommitSignatureProtection, @@ -178,6 +180,7 @@ describe('github:branch-protection:create', () => { requireLastPushApproval: true, requiredCommitSigning: true, requiredLinearHistory: true, + blockCreations: true, }, }); @@ -211,6 +214,7 @@ describe('github:branch-protection:create', () => { }, required_conversation_resolution: true, required_linear_history: true, + block_creations: true, }); expect( mockOctokit.rest.repos.createCommitSignatureProtection, From 172d57c6b02afa8fec1c5c70c7fca0c734ba99bd Mon Sep 17 00:00:00 2001 From: Praphull <1288395+praphull-purohit@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:51:33 +0200 Subject: [PATCH 14/25] Add API Report Signed-off-by: Praphull <1288395+praphull-purohit@users.noreply.github.com> --- plugins/scaffolder-backend-module-github/report.api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 85ca128c34..35e4e71c3c 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -81,6 +81,7 @@ export function createGithubBranchProtectionAction(options: { requireLastPushApproval?: boolean | undefined; requiredCommitSigning?: boolean | undefined; requiredLinearHistory?: boolean | undefined; + blockCreations?: boolean | undefined; token?: string | undefined; }, { From 2f79f0bac84b762651a4aa2d410d564e79efa1ad Mon Sep 17 00:00:00 2001 From: Praphull <1288395+praphull-purohit@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:54:59 +0200 Subject: [PATCH 15/25] Fix tests in examples Signed-off-by: Praphull <1288395+praphull-purohit@users.noreply.github.com> --- .../src/actions/githubBranchProtection.examples.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.test.ts index 31a15f6019..b02d451992 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.test.ts @@ -99,6 +99,7 @@ describe('github:branch-protection:create', () => { }, required_conversation_resolution: false, required_linear_history: false, + block_creations: false, }); expect( mockOctokit.rest.repos.createCommitSignatureProtection, @@ -132,6 +133,7 @@ describe('github:branch-protection:create', () => { }, required_conversation_resolution: false, required_linear_history: false, + block_creations: false, }); expect( mockOctokit.rest.repos.createCommitSignatureProtection, @@ -165,6 +167,7 @@ describe('github:branch-protection:create', () => { }, required_conversation_resolution: true, required_linear_history: false, + block_creations: false, }); expect( mockOctokit.rest.repos.createCommitSignatureProtection, @@ -202,6 +205,7 @@ describe('github:branch-protection:create', () => { }, required_conversation_resolution: true, required_linear_history: true, + block_creations: false, }); expect( mockOctokit.rest.repos.createCommitSignatureProtection, From a599afa296384a1e39cd7db866593265128a27ef Mon Sep 17 00:00:00 2001 From: Praphull <1288395+praphull-purohit@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:57:14 +0200 Subject: [PATCH 16/25] Minor correction in changeset Signed-off-by: Praphull <1288395+praphull-purohit@users.noreply.github.com> --- .changeset/ripe-boxes-stand.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ripe-boxes-stand.md b/.changeset/ripe-boxes-stand.md index e40dedbe90..163f5ae545 100644 --- a/.changeset/ripe-boxes-stand.md +++ b/.changeset/ripe-boxes-stand.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-backend-module-github': minor --- -Add field block_creations in github branch protection scaffolder actions +Add field blockCreations in github branch protection scaffolder actions From 5a767f5c8abf8dbc5677e590d2385037ff6e72b4 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Thu, 4 Sep 2025 13:22:34 +0200 Subject: [PATCH 17/25] update docs for table component Signed-off-by: Emma Indal --- docs-ui/src/content/components/table.props.ts | 38 ++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/docs-ui/src/content/components/table.props.ts b/docs-ui/src/content/components/table.props.ts index 87be472609..1b40055849 100644 --- a/docs-ui/src/content/components/table.props.ts +++ b/docs-ui/src/content/components/table.props.ts @@ -217,18 +217,18 @@ export const cellPropDefs: Record = { }; export const tablePaginationPropDefs: Record = { - pageIndex: { + offset: { type: 'number', - description: 'The current page index.', + description: 'The current offset (starting index) for pagination.', }, pageSize: { type: 'number', description: 'The number of items per page.', }, - setPageIndex: { + setOffset: { type: 'enum', - values: ['(pageIndex: number) => void'], - description: 'Handler that is called when the page index changes.', + values: ['(offset: number) => void'], + description: 'Handler that is called when the offset changes.', }, setPageSize: { type: 'enum', @@ -277,10 +277,7 @@ export const tableUsageSnippet = `import { Cell, ..., TableHeader, TablePaginati `; -export const tableBasicSnippet = `import { Table, TablePagination } from '@backstage/ui'; - -const [pageIndex, setPageIndex] = useState(0); -const [pageSize, setPageSize] = useState(5); +export const tableBasicSnippet = `import { Table, TableHeader, Column, TableBody, Row, Cell, CellProfile, TablePagination, useTable } from '@backstage/ui'; const data = [ { @@ -293,10 +290,13 @@ const data = [ // ... more data ]; -const newData = data4.slice( - pageIndex * pageSize, - (pageIndex + 1) * pageSize, -); +// Uncontrolled pagination (easiest) +const { data: paginatedData, paginationProps } = useTable({ + data, + pagination: { + defaultPageSize: 5, + }, +}); @@ -306,9 +306,9 @@ const newData = data4.slice( Albums - {newData.map(item => ( + {paginatedData?.map(item => ( -
-`; +`; From da404afd70598dbe54dbc261ef14b3a6b2ad1425 Mon Sep 17 00:00:00 2001 From: Praphull <1288395+praphull-purohit@users.noreply.github.com> Date: Fri, 5 Sep 2025 09:20:53 +0200 Subject: [PATCH 18/25] Incorporate code review suggestion Signed-off-by: Praphull <1288395+praphull-purohit@users.noreply.github.com> --- .changeset/ripe-boxes-stand.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ripe-boxes-stand.md b/.changeset/ripe-boxes-stand.md index 163f5ae545..f76f7b2516 100644 --- a/.changeset/ripe-boxes-stand.md +++ b/.changeset/ripe-boxes-stand.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-backend-module-github': minor --- -Add field blockCreations in github branch protection scaffolder actions +Add block creations field in github branch protection scaffolder actions From 0ab3ae36541e8beee0a9f52ac8e323268833e14d Mon Sep 17 00:00:00 2001 From: Praphull Purohit <1288395+praphull-purohit@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:10:01 +0200 Subject: [PATCH 19/25] Change from minor to patch Co-authored-by: Paul Schultz Signed-off-by: Praphull Purohit <1288395+praphull-purohit@users.noreply.github.com> --- .changeset/ripe-boxes-stand.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ripe-boxes-stand.md b/.changeset/ripe-boxes-stand.md index f76f7b2516..9a1e72f052 100644 --- a/.changeset/ripe-boxes-stand.md +++ b/.changeset/ripe-boxes-stand.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-scaffolder-backend-module-github': minor +'@backstage/plugin-scaffolder-backend-module-github': patch --- Add block creations field in github branch protection scaffolder actions From 02110671648a8cd8de00c57c5fd622c5e445a856 Mon Sep 17 00:00:00 2001 From: Praphull Purohit <1288395+praphull-purohit@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:10:28 +0200 Subject: [PATCH 20/25] Remove blockCreations defaulting Apply suggestion from @schultzp2020 Co-authored-by: Paul Schultz Signed-off-by: Praphull Purohit <1288395+praphull-purohit@users.noreply.github.com> --- .../src/actions/githubBranchProtection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts index 6ae0f67135..ee8555661b 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts @@ -77,7 +77,7 @@ export function createGithubBranchProtectionAction(options: { requireLastPushApproval = false, requiredCommitSigning = false, requiredLinearHistory = false, - blockCreations = false, + blockCreations, token: providedToken, } = ctx.input; From 7bd14534b86fab32d5dfbf0f4d5fcc2b6e8c9557 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Wed, 10 Sep 2025 11:10:39 +0530 Subject: [PATCH 21/25] Made the chages to the software catalog Link Signed-off-by: Aditya Kumar --- docs/frontend-system/building-plugins/01-index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md index 03da20ea73..283c162fa7 100644 --- a/docs/frontend-system/building-plugins/01-index.md +++ b/docs/frontend-system/building-plugins/01-index.md @@ -185,7 +185,7 @@ export const examplePlugin = createFrontendPlugin({ ## Plugin specific extensions -There are many different plugins that you can extend with additional functionality through extensions. One such plugin is [the catalog plugin](https://backstage.io/docs/features/software-catalog/), one of the core features of Backstage. It lets you catalog the software in your organization, where each item in the catalog has its own page that can be populated with tools and information relating to that catalog entity. In this example we will explore how our plugin can provide such a tool to display on an entity page. +There are many different plugins that you can extend with additional functionality through extensions. One such plugin is [the catalog plugin](../../features/software-catalog/), one of the core features of Backstage. It lets you catalog the software in your organization, where each item in the catalog has its own page that can be populated with tools and information relating to that catalog entity. In this example we will explore how our plugin can provide such a tool to display on an entity page. ```tsx title="in src/plugin.ts - An example entity content extension" import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; From e72c879b09c344602f330545f8700e84a2c8c896 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Wed, 10 Sep 2025 12:16:48 +0530 Subject: [PATCH 22/25] Updated Note format Signed-off-by: Aditya Kumar --- plugins/catalog-backend/src/schema/openapi.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index baaba44d48..e4282f1700 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -6,8 +6,9 @@ info: The API surface consists of a few distinct groups of functionality. Each has a dedicated section below. - > **Note:** This page only describes some of the most commonly used parts of the - > API, and is a work in progress. + :::note Note + This page only describes some of the most commonly used parts of the API, and is a work in progress. + ::: All of the URL paths in this article are assumed to be on top of some base URL pointing at your catalog installation. For example, if the path given in a From e0db9b8534c968daa98d2c8fbb9bfa028545e263 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Thu, 11 Sep 2025 19:15:19 +0200 Subject: [PATCH 23/25] fix(cli): identify custom patterns that extend the default pattern If a custom pattern is used, `backstage.json` will not be updated. However, if the custom pattern extends the default pattern, `backstage.json` will be updated, too. Example: - `@backstage/*` (default) - `@{backstage,backstage-community}/*` - `@{extra1,backstage,extra2}/*` Fixes: #28839 Signed-off-by: Patrick Jungermann --- .changeset/fast-corners-roll.md | 11 +++++++++++ .../modules/migrate/commands/versions/bump.test.ts | 3 ++- .../src/modules/migrate/commands/versions/bump.ts | 13 +++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 .changeset/fast-corners-roll.md diff --git a/.changeset/fast-corners-roll.md b/.changeset/fast-corners-roll.md new file mode 100644 index 0000000000..f883774aef --- /dev/null +++ b/.changeset/fast-corners-roll.md @@ -0,0 +1,11 @@ +--- +'@backstage/cli': patch +--- + +Modify the `backstage.json` also for custom patterns if it extends the default pattern. + +Examples: + +- `@backstage/*` (default pattern) +- `@{backstage,backstage-community}/*` +- `@{extra1,backstage,extra2}/*` diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts index a309ddb192..1fb28d0289 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts @@ -772,6 +772,7 @@ describe('bump', () => { res( ctx.status(200), ctx.json({ + releaseVersion: '1.0.0', packages: [], }), ), @@ -797,7 +798,7 @@ describe('bump', () => { 'bumping @backstage-extra/custom in b to ^1.1.0', 'bumping @backstage-extra/custom-two in b to ^2.0.0', 'bumping @backstage/theme in b to ^2.0.0', - 'Skipping backstage.json update as custom pattern is used', + 'Your project is now at version 1.0.0, which has been written to backstage.json', 'Running yarn install to install new versions', 'Checking for moved packages to the @backstage-community namespace...', '⚠️ The following packages may have breaking changes:', diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli/src/modules/migrate/commands/versions/bump.ts index 23202ee1b5..391ccf8f22 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.ts @@ -18,6 +18,7 @@ maybeBootstrapProxy(); import fs from 'fs-extra'; import chalk from 'chalk'; +import { minimatch } from 'minimatch'; import semver from 'semver'; import { OptionValues } from 'commander'; import yaml from 'yaml'; @@ -78,6 +79,14 @@ type PkgVersionInfo = { location: string; }; +function extendsDefaultPattern(pattern: string): boolean { + if (!pattern.endsWith('/*')) { + return false; + } + + return minimatch('@backstage/', pattern.slice(0, -1)); +} + export default async (opts: OptionValues) => { const lockfilePath = paths.resolveTargetRoot('yarn.lock'); const lockfile = await Lockfile.load(lockfilePath); @@ -245,8 +254,8 @@ export default async (opts: OptionValues) => { console.log(); - // Do not update backstage.json when upgrade patterns are used. - if (pattern === DEFAULT_PATTERN_GLOB) { + // Do not update backstage.json when default pattern is not covered + if (extendsDefaultPattern(pattern)) { await bumpBackstageJsonVersion( releaseManifest.releaseVersion, hasYarnPlugin, From c68cb321aeaab40d83e0a0672c5d6f7b548b13ce Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 12 Sep 2025 15:06:25 +0200 Subject: [PATCH 24/25] chore: exit pre-release Signed-off-by: benjdlambert --- .changeset/pre.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index b1a840849d..174818edf4 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,5 +1,5 @@ { - "mode": "pre", + "mode": "exit", "tag": "next", "initialVersions": { "example-app": "0.2.112", From 7151260d275694c08e689c81366ef76457314a05 Mon Sep 17 00:00:00 2001 From: Alec Jacobs Date: Fri, 12 Sep 2025 14:20:12 -0700 Subject: [PATCH 25/25] fix(scaffolder): prevent MultiEntityPicker from removing existing options from form data Signed-off-by: Alec Jacobs --- .changeset/two-coins-stop.md | 5 ++ .../MultiEntityPicker.test.tsx | 65 +++++++++++++++++++ .../MultiEntityPicker/MultiEntityPicker.tsx | 47 +++++++++----- 3 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 .changeset/two-coins-stop.md diff --git a/.changeset/two-coins-stop.md b/.changeset/two-coins-stop.md new file mode 100644 index 0000000000..005ea81121 --- /dev/null +++ b/.changeset/two-coins-stop.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Prevent the MultiEntityPicker from removing options present in form state when new options are selected diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx index a02d793a34..fc824f20f9 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx @@ -262,6 +262,71 @@ describe('', () => { }); }); + describe('with existing form data', () => { + beforeEach(() => { + uiSchema = { 'ui:options': {} }; + props = { + onChange, + schema, + required, + uiSchema, + rawErrors, + formData: ['group:default/team-a'], + } as unknown as FieldProps; + }); + + it('preserves existing data on blur', async () => { + const { getByRole } = await renderInTestApp( + + + , + ); + + const input = getByRole('textbox'); + + fireEvent.change(input, { target: { value: 'squ' } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith(['group:default/team-a', 'squ']); + }); + + it('preserves existing data on value create', async () => { + const { getByRole } = await renderInTestApp( + + + , + ); + + const input = getByRole('textbox'); + + fireEvent.change(input, { target: { value: 'squ' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + + expect(onChange).toHaveBeenCalledWith(['group:default/team-a', 'squ']); + }); + + it('preserves existing data on selecting an existing option', async () => { + catalogApi.getEntities.mockResolvedValue({ items: entities }); + + const { getByRole } = await renderInTestApp( + + + , + ); + + const input = getByRole('textbox'); + + fireEvent.mouseDown(input); + const optionA = screen.getByText('squad-b'); + await userEvent.click(optionA as HTMLElement); + + expect(onChange).toHaveBeenCalledWith([ + 'group:default/team-a', + 'group:default/squad-b', + ]); + }); + }); + describe('uses full entity ref', () => { beforeEach(() => { uiSchema = { diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx index 57c4b87d6d..39130c4b78 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx @@ -50,6 +50,12 @@ import { scaffolderTranslationRef } from '../../../translation'; export { MultiEntityPickerSchema } from './schema'; +// AutocompleteChangeReason events that can be triggered when a user inputs a freeSolo option +const FREE_SOLO_EVENTS: readonly AutocompleteChangeReason[] = [ + 'blur', + 'create-option', +]; + /** * The underlying component that is rendered in the form for the `MultiEntityPicker` * field extension. @@ -110,29 +116,34 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => { (_: any, refs: (string | Entity)[], reason: AutocompleteChangeReason) => { const values = refs .map(ref => { + // If the ref is not a string, then it was a selected option in the picker if (typeof ref !== 'string') { // if ref does not exist: pass 'undefined' to trigger validation for required value return ref ? stringifyEntityRef(ref as Entity) : undefined; } - if (reason === 'blur' || reason === 'create-option') { - // Add in default namespace, etc. - let entityRef = ref; - try { - // Attempt to parse the entity ref into it's full form. - entityRef = stringifyEntityRef( - parseEntityRef(ref as string, { - defaultKind, - defaultNamespace, - }), - ); - } catch (err) { - // If the passed in value isn't an entity ref, do nothing. - } - // We need to check against formData here as that's the previous value for this field. - if (formData?.includes(ref) || allowArbitraryValues) { - return entityRef; - } + // Add in default namespace, etc. + let entityRef = ref; + try { + // Attempt to parse the entity ref into it's full form. + entityRef = stringifyEntityRef( + parseEntityRef(ref as string, { + defaultKind, + defaultNamespace, + }), + ); + } catch (err) { + // If the passed in value isn't an entity ref, do nothing. + } + + // We need to check against formData here as that's the previous value for this field. + if ( + // If value already matches what exists in form data, allow it + formData?.includes(ref) || + // If arbitrary values are allowed and the reason is a free solo event, allow it + (allowArbitraryValues && FREE_SOLO_EVENTS.includes(reason)) + ) { + return entityRef; } return undefined;