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/.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/.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/.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", diff --git a/.changeset/ripe-boxes-stand.md b/.changeset/ripe-boxes-stand.md new file mode 100644 index 0000000000..9a1e72f052 --- /dev/null +++ b/.changeset/ripe-boxes-stand.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': patch +--- + +Add block creations field in github branch protection scaffolder actions diff --git a/.changeset/stupid-pans-flash.md b/.changeset/stupid-pans-flash.md new file mode 100644 index 0000000000..3e8c2333dd --- /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 }} +``` 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/.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/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 => ( -
-`; +`; 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'; 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, 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}'`, diff --git a/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.test.ts b/plugins/catalog-backend-module-gitlab/src/GitLabDiscoveryProcessor.test.ts index d75d0e224e..774da46553 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, + _ => { + 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, + _ => { + 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, + _ => { + 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 f052434035..31ac7df6f1 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]; @@ -675,3 +690,115 @@ describe('GitlabDiscoveryEntityProvider - events', () => { expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); }); }); + +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 08bd498615..11b6a0ce04 100644 --- a/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts +++ b/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.ts @@ -326,6 +326,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 }), }, ); 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 diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 4e55240955..30cdd91704 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'; } >; @@ -516,6 +528,40 @@ export type EntityPredicateValue = $contains: EntityPredicateExpression; }; +// @alpha (undocumented) +export const EntityTableColumnTitle: ({ + translationKey, +}: EntityTableColumnTitleProps) => + | 'Title' + | 'Domain' + | 'System' + | 'Lifecycle' + | 'Namespace' + | 'Owner' + | 'Tags' + | 'Type' + | 'Name' + | 'Description' + | 'Targets' + | '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', }; diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 85ca128c34..8f1bc23785 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; }, { @@ -136,6 +137,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; 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.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, 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, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts index f82bfc6950..ee8555661b 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, 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/githubIssuesCreate.examples.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts new file mode 100644 index 0000000000..7d767f0337 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.examples.ts @@ -0,0 +1,73 @@ +/* + * 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'; + +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..474acf14d7 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.test.ts @@ -0,0 +1,160 @@ +/* + * 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, + 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..5c5703e20d --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesCreate.ts @@ -0,0 +1,182 @@ +/* + * 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, +} 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/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, }; 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, 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;