From da54fd24ef3df1d8e33b19e757c294d2211dc172 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 2 Mar 2026 12:43:44 +0000 Subject: [PATCH 01/16] Update tokens.css Signed-off-by: Charles de Dreuille --- packages/ui/src/css/tokens.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/css/tokens.css b/packages/ui/src/css/tokens.css index 3d5deac4cc..166548db0a 100644 --- a/packages/ui/src/css/tokens.css +++ b/packages/ui/src/css/tokens.css @@ -80,8 +80,8 @@ --bui-bg-app: #f8f8f8; --bui-bg-neutral-1: #fff; - --bui-bg-neutral-1-hover: oklch(0% 0 0 / 12%); - --bui-bg-neutral-1-pressed: oklch(0% 0 0 / 16%); + --bui-bg-neutral-1-hover: oklch(0% 0 0 / 6%); + --bui-bg-neutral-1-pressed: oklch(0% 0 0 / 12%); --bui-bg-neutral-1-disabled: oklch(0% 0 0 / 6%); --bui-bg-neutral-2: oklch(0% 0 0 / 6%); From 58224d347645baf7edccbcb1a91ef267c57acf55 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 2 Mar 2026 12:52:54 +0000 Subject: [PATCH 02/16] Create bumpy-colts-teach.md Signed-off-by: Charles de Dreuille --- .changeset/bumpy-colts-teach.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bumpy-colts-teach.md diff --git a/.changeset/bumpy-colts-teach.md b/.changeset/bumpy-colts-teach.md new file mode 100644 index 0000000000..e3457127ce --- /dev/null +++ b/.changeset/bumpy-colts-teach.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Fixed neutral-1 hover & pressed state in light mode. From d4fa5b4ee091e8f92317c3fe7ee3dc45a80beff9 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Fri, 27 Feb 2026 10:44:04 +0100 Subject: [PATCH 03/16] fix(ui): strip query params from tab href before active-state matching Tab matchStrategy ('exact' and 'prefix') compared the raw tab href against location.pathname, which never includes query params. This meant tabs with query params in their href could never be matched as active. Extract an hrefPathname helper that strips query params and hash fragments, and use it in both isTabActive and the segment count computation. Signed-off-by: Johan Persson --- .changeset/fix-tab-match-query-params.md | 7 +++ .../ui/src/components/Tabs/Tabs.stories.tsx | 45 +++++++++++++++++++ packages/ui/src/components/Tabs/Tabs.tsx | 16 +++++-- 3 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-tab-match-query-params.md diff --git a/.changeset/fix-tab-match-query-params.md b/.changeset/fix-tab-match-query-params.md new file mode 100644 index 0000000000..6e01a202f5 --- /dev/null +++ b/.changeset/fix-tab-match-query-params.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed tab `matchStrategy` matching to ignore query parameters and hash fragments in tab `href` values. Previously, tabs with query params in their `href` (e.g., `/page?group=foo`) would never show as active since matching compared the full `href` string against `location.pathname` which never includes query params. + +**Affected components:** Tabs, PluginHeader diff --git a/packages/ui/src/components/Tabs/Tabs.stories.tsx b/packages/ui/src/components/Tabs/Tabs.stories.tsx index 2a02bedc8c..fcc919f294 100644 --- a/packages/ui/src/components/Tabs/Tabs.stories.tsx +++ b/packages/ui/src/components/Tabs/Tabs.stories.tsx @@ -454,6 +454,51 @@ export const RootPathMatching = meta.story({ ), }); +export const HrefWithQueryParams = meta.story({ + args: { + children: '', + }, + render: () => ( + + + + + Dashboard + + + Alerts + + + + + + Current URL: /cost-insights/dashboard?group=bar + + + Tab hrefs include query params (e.g., ?group=foo) but the current URL + has different query params (?group=bar). + + + • "Dashboard" tab: IS active — matching ignores query params and + compares only the pathname. + + + • "Alerts" tab: NOT active — pathname /cost-insights/alerts doesn't + match /cost-insights/dashboard. + + + + ), +}); + export const AutoSelectionOfTabs = meta.story({ args: { children: '', diff --git a/packages/ui/src/components/Tabs/Tabs.tsx b/packages/ui/src/components/Tabs/Tabs.tsx index cf6263b9a4..d625d2abf0 100644 --- a/packages/ui/src/components/Tabs/Tabs.tsx +++ b/packages/ui/src/components/Tabs/Tabs.tsx @@ -79,6 +79,12 @@ const TabSelectionContext = createContext( null, ); +/** + * Strips query params and hash from a href, leaving only the pathname. + * Tab matching always compares against location.pathname which never includes them. + */ +const hrefPathname = (href: string) => href.split('?')[0].split('#')[0]; + /** * Utility function to determine if a tab should be active based on the matching strategy. * This follows the pattern used in WorkaroundNavLink from the sidebar. @@ -88,18 +94,20 @@ const isTabActive = ( currentPathname: string, matchStrategy: 'exact' | 'prefix', ): boolean => { + const pathname = hrefPathname(tabHref); + if (matchStrategy === 'exact') { - return tabHref === currentPathname; + return pathname === currentPathname; } // Prefix matching - similar to WorkaroundNavLink behavior - if (tabHref === currentPathname) { + if (pathname === currentPathname) { return true; } // Check if current path starts with tab href followed by a slash // This prevents /foo matching /foobar - return currentPathname.startsWith(`${tabHref}/`); + return currentPathname.startsWith(`${pathname}/`); }; /** @@ -304,7 +312,7 @@ function RoutedTabEffects({ // Register as active tab when URL matches (for tab selection) const isActive = isTabActive(href, location.pathname, matchStrategy); - const segmentCount = href.split('/').filter(Boolean).length; + const segmentCount = hrefPathname(href).split('/').filter(Boolean).length; useEffect(() => { if (isActive && selectionCtx) { From a0e4d38f4bcfbbee277d5585f867e6860cf44141 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Fri, 27 Feb 2026 10:58:24 +0100 Subject: [PATCH 04/16] fix(ui): use Text as="p" in Tabs stories for proper line breaks Story description text was rendering inline without line breaks between elements. Add as="p" to Text components so each renders as a block-level paragraph. Signed-off-by: Johan Persson --- .../ui/src/components/Tabs/Tabs.stories.tsx | 74 ++++++++++--------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/packages/ui/src/components/Tabs/Tabs.stories.tsx b/packages/ui/src/components/Tabs/Tabs.stories.tsx index fcc919f294..55cdf7c446 100644 --- a/packages/ui/src/components/Tabs/Tabs.stories.tsx +++ b/packages/ui/src/components/Tabs/Tabs.stories.tsx @@ -125,10 +125,10 @@ export const WithMockedURLTab3 = meta.story({ - + Current URL is mocked to be: /tab3 - + Notice how the "Tab 3 With long title" tab is selected (highlighted) because it matches the current path. @@ -157,14 +157,14 @@ export const WithMockedURLNoMatch = meta.story({ - + Current URL is mocked to be: /some-other-page - + No tab is selected because the current path doesn't match any tab's href. - + Tabs without href (like "Tab 1", "Tab 2", "Tab 3 With long title") fall back to React Aria's internal state. @@ -195,14 +195,14 @@ export const ExactMatchingDefault = meta.story({ - + Current URL: /mentorship/events - + Using default exact matching, only the "Events" tab is active because it exactly matches the URL. - + The "Mentorship" tab is NOT active even though the URL contains "/mentorship". @@ -231,18 +231,18 @@ export const PrefixMatchingForNestedRoutes = meta.story({ - + Current URL: /mentorship/events - + The "Mentorship" tab uses prefix matching and IS active because "/mentorship/events" starts with "/mentorship". - + The "Events" tab uses exact matching and is also active because it exactly matches. - + The "Catalog" tab uses prefix matching but is NOT active because the URL doesn't start with "/catalog". @@ -313,22 +313,22 @@ export const MixedMatchingStrategies = meta.story({ - + Current URL: /dashboard/analytics/reports - + • "Overview" tab: exact matching, NOT active (doesn't exactly match "/dashboard") - + • "Analytics" tab: prefix matching, IS active (URL starts with "/dashboard/analytics") - + • "Settings" tab: prefix matching, NOT active (URL doesn't start with "/dashboard/settings") - + • "Help" tab: exact matching, NOT active (doesn't exactly match "/help") @@ -357,20 +357,20 @@ export const PrefixMatchingEdgeCases = meta.story({ - + Current URL: /foobar - + • "Foo" tab (prefix): NOT active - prevents "/foo" from matching "/foobar" - + • "Foobar" tab (exact): IS active - exactly matches "/foobar" - + • "Foo (exact)" tab: NOT active - doesn't exactly match "/foobar" - + This shows that prefix matching properly requires a "/" separator to prevent false matches. @@ -399,20 +399,20 @@ export const PrefixMatchingWithSlash = meta.story({ - + Current URL: /foo/bar - + • "Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/" - + • "Foobar" tab (exact): NOT active - doesn't exactly match "/foobar" - + • "Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with "/bar" - + This demonstrates proper prefix matching with the "/" separator. @@ -440,12 +440,14 @@ export const RootPathMatching = meta.story({ - + Current URL: / - • "Home" tab (exact): IS active - exactly matches "/" - • "Home (prefix)" tab: IS active - "/" matches "/" - + + • "Home" tab (exact): IS active - exactly matches "/" + + • "Home (prefix)" tab: IS active - "/" matches "/" + • "Catalog" tab (prefix): NOT active - "/" doesn't start with "/catalog" @@ -533,10 +535,12 @@ export const AutoSelectionOfTabs = meta.story({ {/* With hrefs */} - - {' '} - Case 2: With hrefs By default no selection is shown - because the URL doesn't match any tab's href.{' '} + + Case 2: With hrefs + + + By default no selection is shown because the URL doesn't match any + tab's href. From 993a59840bd7be504ed0fcc39e869c76712eb8f2 Mon Sep 17 00:00:00 2001 From: James Brooks Date: Mon, 2 Mar 2026 15:02:32 +0000 Subject: [PATCH 05/16] Update Azure integration config visibility Signed-off-by: James Brooks --- .changeset/itchy-words-crash.md | 5 +++++ packages/integration/config.d.ts | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/itchy-words-crash.md diff --git a/.changeset/itchy-words-crash.md b/.changeset/itchy-words-crash.md new file mode 100644 index 0000000000..a37ea74477 --- /dev/null +++ b/.changeset/itchy-words-crash.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Fixed Azure integration config schema visibility annotations to use per-field `@visibility secret` instead of `@deepVisibility secret` on parent objects, so that non-secret fields like `clientId`, `tenantId`, `organizations`, and `managedIdentityClientId` are no longer incorrectly marked as secret. diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index add237a6b9..0ebcd54dc6 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -54,13 +54,14 @@ export interface Config { * If no organization matches the first credential without an organization is used. * * If no credentials are specified at all, either a default credential (for Azure DevOps) or anonymous access (for Azure DevOps Server) is used. - * @deepVisibility secret */ credentials?: { organizations?: string[]; clientId?: string; + /** @visibility secret */ clientSecret?: string; tenantId?: string; + /** @visibility secret */ personalAccessToken?: string; managedIdentityClientId?: string; }[]; @@ -111,7 +112,6 @@ export interface Config { endpoint?: string; /** * Optional credential to use for Azure Active Directory authentication. - * @deepVisibility secret */ aadCredential?: { /** @@ -126,6 +126,7 @@ export interface Config { /** * The client secret for the Azure AD application. + * @visibility secret */ clientSecret: string; }; From f867152e8bfd431fd88679ba12cd36e554692199 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 2 Mar 2026 17:33:35 +0100 Subject: [PATCH 06/16] cli: fix prepack and postpack commands CJS compat Switch the prepack and postpack commands from using a direct dynamic import() to the execute.loader pattern, which properly handles CJS double-wrapping of module exports. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/fix-prepack-postpack-cjs.md | 5 +++++ packages/cli/src/modules/build/index.ts | 30 ++++++++++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-prepack-postpack-cjs.md diff --git a/.changeset/fix-prepack-postpack-cjs.md b/.changeset/fix-prepack-postpack-cjs.md new file mode 100644 index 0000000000..549841845b --- /dev/null +++ b/.changeset/fix-prepack-postpack-cjs.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fixed `prepack` and `postpack` commands failing when the dynamic `import()` goes through a CommonJS compatibility layer that doesn't wrap module exports. diff --git a/packages/cli/src/modules/build/index.ts b/packages/cli/src/modules/build/index.ts index fd94cb5db6..794f5b8976 100644 --- a/packages/cli/src/modules/build/index.ts +++ b/packages/cli/src/modules/build/index.ts @@ -215,20 +215,34 @@ export const buildPlugin = createCliPlugin({ reg.addCommand({ path: ['package', 'prepack'], description: 'Prepares a package for packaging before publishing', - execute: async ({ args, info }) => { - cli({ help: info }, undefined, args); - const { pre } = await import('./commands/package/pack'); - await pre(); + execute: { + loader: () => + import('./commands/package/pack').then(m => { + const { pre } = (m as any).default ?? m; + return { + default: async ({ args, info }) => { + cli({ help: info }, undefined, args); + await pre(); + }, + }; + }), }, }); reg.addCommand({ path: ['package', 'postpack'], description: 'Restores the changes made by the prepack command', - execute: async ({ args, info }) => { - cli({ help: info }, undefined, args); - const { post } = await import('./commands/package/pack'); - await post(); + execute: { + loader: () => + import('./commands/package/pack').then(m => { + const { post } = (m as any).default ?? m; + return { + default: async ({ args, info }) => { + cli({ help: info }, undefined, args); + await post(); + }, + }; + }), }, }); From 4105a78f984875d913202f44104638f5a8372808 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 2 Mar 2026 19:07:03 +0000 Subject: [PATCH 07/16] Style improvements on PluginHeader Signed-off-by: Charles de Dreuille --- .changeset/odd-laws-attack.md | 7 ++ .storybook/themes/spotify.css | 24 ++----- packages/ui/report.api.md | 8 ++- .../PluginHeader/PluginHeader.module.css | 23 ------- .../components/PluginHeader/PluginHeader.tsx | 41 +++++++++--- .../PluginHeader/PluginHeaderToolbar.tsx | 66 ------------------- .../src/components/PluginHeader/definition.ts | 38 +++-------- .../ui/src/components/PluginHeader/types.ts | 21 ------ 8 files changed, 61 insertions(+), 167 deletions(-) create mode 100644 .changeset/odd-laws-attack.md delete mode 100644 packages/ui/src/components/PluginHeader/PluginHeaderToolbar.tsx diff --git a/.changeset/odd-laws-attack.md b/.changeset/odd-laws-attack.md new file mode 100644 index 0000000000..034cb10be3 --- /dev/null +++ b/.changeset/odd-laws-attack.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Merged the internal `PluginHeaderToolbar` component into `PluginHeader`, removing the separate component and its associated types (`PluginHeaderToolbarOwnProps`, `PluginHeaderToolbarProps`) and definition (`PluginHeaderToolbarDefinition`). This is an internal refactor with no changes to the public API of `PluginHeader`. + +**Affected components:** PluginHeader diff --git a/.storybook/themes/spotify.css b/.storybook/themes/spotify.css index 89b71c48d3..3aa7580748 100644 --- a/.storybook/themes/spotify.css +++ b/.storybook/themes/spotify.css @@ -183,26 +183,16 @@ font-weight: var(--bui-font-weight-regular); } - .bui-HeaderToolbar { - padding-top: var(--bui-space-2); - padding-inline: var(--bui-space-2); - } - - .bui-HeaderToolbarWrapper { - border-radius: var(--bui-radius-3); - padding-inline: var(--bui-space-3); + .bui-PluginHeaderToolbarWrapper { + padding: 0; border: none; + height: 32px; } - .bui-HeaderToolbarControls { - right: calc(var(--bui-space-3) - 1px); - } - - .bui-HeaderTabsWrapper { - margin-top: var(--bui-space-2); - margin-inline: var(--bui-space-2); - border-radius: var(--bui-radius-3); - padding-inline: var(--bui-space-1); + .bui-PluginHeaderTabsWrapper { + margin: 0; + padding: 0; + margin-left: -8px; border: none; } diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index c77d044d46..572b6701d7 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1745,7 +1745,13 @@ export const PluginHeaderDefinition: { }; readonly classNames: { readonly root: 'bui-PluginHeader'; - readonly tabsWrapper: 'bui-PluginHeaderTabsWrapper'; + readonly toolbar: 'bui-PluginHeaderToolbar'; + readonly toolbarWrapper: 'bui-PluginHeaderToolbarWrapper'; + readonly toolbarContent: 'bui-PluginHeaderToolbarContent'; + readonly toolbarControls: 'bui-PluginHeaderToolbarControls'; + readonly toolbarIcon: 'bui-PluginHeaderToolbarIcon'; + readonly toolbarName: 'bui-PluginHeaderToolbarName'; + readonly tabs: 'bui-PluginHeaderTabsWrapper'; }; readonly propDefs: { readonly icon: {}; diff --git a/packages/ui/src/components/PluginHeader/PluginHeader.module.css b/packages/ui/src/components/PluginHeader/PluginHeader.module.css index 037fd00722..9b6cc7d77d 100644 --- a/packages/ui/src/components/PluginHeader/PluginHeader.module.css +++ b/packages/ui/src/components/PluginHeader/PluginHeader.module.css @@ -17,26 +17,7 @@ @layer tokens, base, components, utilities; @layer components { - .bui-PluginHeader { - display: block; - } - - .bui-PluginHeaderToolbar { - &::before { - content: ''; - position: absolute; - top: 0; - left: 0px; - right: 0px; - height: 16px; - background-color: var(--bui-bg-app); - z-index: 0; - } - } - .bui-PluginHeaderToolbarWrapper { - position: relative; - z-index: 1; display: flex; flex-direction: row; align-items: center; @@ -77,10 +58,6 @@ } .bui-PluginHeaderToolbarControls { - position: absolute; - right: var(--bui-space-5); - top: 50%; - transform: translateY(-50%); display: flex; flex-direction: row; align-items: center; diff --git a/packages/ui/src/components/PluginHeader/PluginHeader.tsx b/packages/ui/src/components/PluginHeader/PluginHeader.tsx index ebdfee490d..6a6dee3016 100644 --- a/packages/ui/src/components/PluginHeader/PluginHeader.tsx +++ b/packages/ui/src/components/PluginHeader/PluginHeader.tsx @@ -15,13 +15,15 @@ */ import type { PluginHeaderProps } from './types'; -import { PluginHeaderToolbar } from './PluginHeaderToolbar'; import { Tabs, TabList, Tab } from '../Tabs'; import { useDefinition } from '../../hooks/useDefinition'; import { PluginHeaderDefinition } from './definition'; import { type NavigateOptions } from 'react-router-dom'; import { useRef } from 'react'; import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect'; +import { Link } from 'react-aria-components'; +import { RiShapesLine } from '@remixicon/react'; +import { Text } from '../Text'; declare module 'react-aria-components' { interface RouterConfig { @@ -49,6 +51,9 @@ export const PluginHeader = (props: PluginHeaderProps) => { const hasTabs = tabs && tabs.length > 0; const headerRef = useRef(null); + const toolbarWrapperRef = useRef(null); + const toolbarContentRef = useRef(null); + const toolbarControlsRef = useRef(null); useIsomorphicLayoutEffect(() => { const el = headerRef.current; @@ -82,17 +87,35 @@ export const PluginHeader = (props: PluginHeaderProps) => { }; }, []); + const titleContent = ( + <> +
{icon || }
+ {title || 'Your plugin'} + + ); + return (
- +
+
+
+ + {titleLink ? ( + + {titleContent} + + ) : ( +
{titleContent}
+ )} +
+
+
+ {customActions} +
+
+
{tabs && ( -
+
{tabs?.map(tab => ( diff --git a/packages/ui/src/components/PluginHeader/PluginHeaderToolbar.tsx b/packages/ui/src/components/PluginHeader/PluginHeaderToolbar.tsx deleted file mode 100644 index fe586e1a4d..0000000000 --- a/packages/ui/src/components/PluginHeader/PluginHeaderToolbar.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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 { Link } from 'react-aria-components'; -import { useDefinition } from '../../hooks/useDefinition'; -import { PluginHeaderToolbarDefinition } from './definition'; -import { useRef } from 'react'; -import { RiShapesLine } from '@remixicon/react'; -import type { PluginHeaderToolbarProps } from './types'; -import { Text } from '../Text'; - -/** - * A component that renders the toolbar section of a plugin header. - * - * @internal - */ -export const PluginHeaderToolbar = (props: PluginHeaderToolbarProps) => { - const { ownProps } = useDefinition(PluginHeaderToolbarDefinition, props); - const { classes, icon, title, titleLink, customActions, hasTabs } = ownProps; - - // Refs for collision detection - const toolbarWrapperRef = useRef(null); - const toolbarContentRef = useRef(null); - const toolbarControlsRef = useRef(null); - - const titleContent = ( - <> -
{icon || }
- {title || 'Your plugin'} - - ); - - return ( -
-
-
- - {titleLink ? ( - - {titleContent} - - ) : ( -
{titleContent}
- )} -
-
-
- {customActions} -
-
-
- ); -}; diff --git a/packages/ui/src/components/PluginHeader/definition.ts b/packages/ui/src/components/PluginHeader/definition.ts index 176ceef352..c9a35aa956 100644 --- a/packages/ui/src/components/PluginHeader/definition.ts +++ b/packages/ui/src/components/PluginHeader/definition.ts @@ -15,10 +15,7 @@ */ import { defineComponent } from '../../hooks/useDefinition'; -import type { - PluginHeaderOwnProps, - PluginHeaderToolbarOwnProps, -} from './types'; +import type { PluginHeaderOwnProps } from './types'; import styles from './PluginHeader.module.css'; /** @@ -29,7 +26,13 @@ export const PluginHeaderDefinition = defineComponent()({ styles, classNames: { root: 'bui-PluginHeader', - tabsWrapper: 'bui-PluginHeaderTabsWrapper', + toolbar: 'bui-PluginHeaderToolbar', + toolbarWrapper: 'bui-PluginHeaderToolbarWrapper', + toolbarContent: 'bui-PluginHeaderToolbarContent', + toolbarControls: 'bui-PluginHeaderToolbarControls', + toolbarIcon: 'bui-PluginHeaderToolbarIcon', + toolbarName: 'bui-PluginHeaderToolbarName', + tabs: 'bui-PluginHeaderTabsWrapper', }, propDefs: { icon: {}, @@ -41,28 +44,3 @@ export const PluginHeaderDefinition = defineComponent()({ className: {}, }, }); - -/** - * Component definition for PluginHeaderToolbar - * @internal - */ -export const PluginHeaderToolbarDefinition = - defineComponent()({ - styles, - classNames: { - root: 'bui-PluginHeaderToolbar', - wrapper: 'bui-PluginHeaderToolbarWrapper', - content: 'bui-PluginHeaderToolbarContent', - controls: 'bui-PluginHeaderToolbarControls', - icon: 'bui-PluginHeaderToolbarIcon', - name: 'bui-PluginHeaderToolbarName', - }, - propDefs: { - icon: {}, - title: {}, - titleLink: {}, - customActions: {}, - hasTabs: {}, - className: {}, - }, - }); diff --git a/packages/ui/src/components/PluginHeader/types.ts b/packages/ui/src/components/PluginHeader/types.ts index f57fe82174..9048b33699 100644 --- a/packages/ui/src/components/PluginHeader/types.ts +++ b/packages/ui/src/components/PluginHeader/types.ts @@ -55,24 +55,3 @@ export interface HeaderTab { */ matchStrategy?: TabMatchStrategy; } - -/** - * Own props for the PluginHeaderToolbar component. - * - * @internal - */ -export interface PluginHeaderToolbarOwnProps { - icon?: PluginHeaderOwnProps['icon']; - title?: PluginHeaderOwnProps['title']; - titleLink?: PluginHeaderOwnProps['titleLink']; - customActions?: PluginHeaderOwnProps['customActions']; - hasTabs?: boolean; - className?: string; -} - -/** - * Props for the PluginHeaderToolbar component. - * - * @internal - */ -export interface PluginHeaderToolbarProps extends PluginHeaderToolbarOwnProps {} From e8a5a20f7ddf375a641df29ffbb5940581adcc67 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 2 Mar 2026 20:49:56 +0100 Subject: [PATCH 08/16] cli: split pack.ts into prepack.ts and postpack.ts Each file now has a default export matching the CommandExecuteFn pattern, removing the need for import wrapping in the loader. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/cli/src/lib/lazy.ts | 46 +++++++++++++++++++ .../build/commands/package/postpack.ts | 25 ++++++++++ .../commands/package/{pack.ts => prepack.ts} | 18 +++----- packages/cli/src/modules/build/index.ts | 23 +--------- 4 files changed, 80 insertions(+), 32 deletions(-) create mode 100644 packages/cli/src/lib/lazy.ts create mode 100644 packages/cli/src/modules/build/commands/package/postpack.ts rename packages/cli/src/modules/build/commands/package/{pack.ts => prepack.ts} (80%) diff --git a/packages/cli/src/lib/lazy.ts b/packages/cli/src/lib/lazy.ts new file mode 100644 index 0000000000..8919ed8c28 --- /dev/null +++ b/packages/cli/src/lib/lazy.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2024 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 { assertError } from '@backstage/errors'; +import { exitWithError } from '../lib/errors'; + +type ActionFunc = (...args: any[]) => Promise; +type ActionExports = { + [KName in keyof TModule as TModule[KName] extends ActionFunc + ? KName + : never]: TModule[KName]; +}; + +// Wraps an action function so that it always exits and handles errors +export function lazy( + moduleLoader: () => Promise, + exportName: keyof ActionExports, +): (...args: any[]) => Promise { + return async (...args: any[]) => { + try { + const mod = await moduleLoader(); + const actualModule = ((mod as any).default ?? + mod) as ActionExports; + const actionFunc = actualModule[exportName] as ActionFunc; + await actionFunc(...args); + + process.exit(0); + } catch (error) { + assertError(error); + exitWithError(error); + } + }; +} diff --git a/packages/cli/src/modules/build/commands/package/postpack.ts b/packages/cli/src/modules/build/commands/package/postpack.ts new file mode 100644 index 0000000000..8ca1c800ce --- /dev/null +++ b/packages/cli/src/modules/build/commands/package/postpack.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2020 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 { cli } from 'cleye'; +import { targetPaths } from '@backstage/cli-common'; +import { revertProductionPack } from '../../lib/packager/productionPack'; +import type { CommandContext } from '../../../../wiring/types'; + +export default async ({ args, info }: CommandContext) => { + cli({ help: info }, undefined, args); + await revertProductionPack(targetPaths.dir); +}; diff --git a/packages/cli/src/modules/build/commands/package/pack.ts b/packages/cli/src/modules/build/commands/package/prepack.ts similarity index 80% rename from packages/cli/src/modules/build/commands/package/pack.ts rename to packages/cli/src/modules/build/commands/package/prepack.ts index 0265c07093..39d92518bc 100644 --- a/packages/cli/src/modules/build/commands/package/pack.ts +++ b/packages/cli/src/modules/build/commands/package/prepack.ts @@ -14,17 +14,17 @@ * limitations under the License. */ -import { - productionPack, - revertProductionPack, -} from '../../lib/packager/productionPack'; -import { targetPaths } from '@backstage/cli-common'; - +import { cli } from 'cleye'; import fs from 'fs-extra'; +import { targetPaths } from '@backstage/cli-common'; +import { productionPack } from '../../lib/packager/productionPack'; import { publishPreflightCheck } from '../../lib/publishing'; import { createTypeDistProject } from '../../lib/typeDistProject'; +import type { CommandContext } from '../../../../wiring/types'; + +export default async ({ args, info }: CommandContext) => { + cli({ help: info }, undefined, args); -export const pre = async () => { publishPreflightCheck({ dir: targetPaths.dir, packageJson: await fs.readJson(targetPaths.resolve('package.json')), @@ -35,7 +35,3 @@ export const pre = async () => { featureDetectionProject: await createTypeDistProject(), }); }; - -export const post = async () => { - await revertProductionPack(targetPaths.dir); -}; diff --git a/packages/cli/src/modules/build/index.ts b/packages/cli/src/modules/build/index.ts index 794f5b8976..1e8d1d2060 100644 --- a/packages/cli/src/modules/build/index.ts +++ b/packages/cli/src/modules/build/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { cli } from 'cleye'; import { Command, Option } from 'commander'; import { createCliPlugin } from '../../wiring/factory'; import { lazy } from '../../wiring/lazy'; @@ -216,16 +215,7 @@ export const buildPlugin = createCliPlugin({ path: ['package', 'prepack'], description: 'Prepares a package for packaging before publishing', execute: { - loader: () => - import('./commands/package/pack').then(m => { - const { pre } = (m as any).default ?? m; - return { - default: async ({ args, info }) => { - cli({ help: info }, undefined, args); - await pre(); - }, - }; - }), + loader: () => import('./commands/package/prepack'), }, }); @@ -233,16 +223,7 @@ export const buildPlugin = createCliPlugin({ path: ['package', 'postpack'], description: 'Restores the changes made by the prepack command', execute: { - loader: () => - import('./commands/package/pack').then(m => { - const { post } = (m as any).default ?? m; - return { - default: async ({ args, info }) => { - cli({ help: info }, undefined, args); - await post(); - }, - }; - }), + loader: () => import('./commands/package/postpack'), }, }); From 11764eff336a68e8881ff2089d7a8181384123c2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 2 Mar 2026 20:50:26 +0100 Subject: [PATCH 09/16] cli: remove stale lib/lazy.ts from previous stash Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/cli/src/lib/lazy.ts | 46 ------------------------------------ 1 file changed, 46 deletions(-) delete mode 100644 packages/cli/src/lib/lazy.ts diff --git a/packages/cli/src/lib/lazy.ts b/packages/cli/src/lib/lazy.ts deleted file mode 100644 index 8919ed8c28..0000000000 --- a/packages/cli/src/lib/lazy.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2024 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 { assertError } from '@backstage/errors'; -import { exitWithError } from '../lib/errors'; - -type ActionFunc = (...args: any[]) => Promise; -type ActionExports = { - [KName in keyof TModule as TModule[KName] extends ActionFunc - ? KName - : never]: TModule[KName]; -}; - -// Wraps an action function so that it always exits and handles errors -export function lazy( - moduleLoader: () => Promise, - exportName: keyof ActionExports, -): (...args: any[]) => Promise { - return async (...args: any[]) => { - try { - const mod = await moduleLoader(); - const actualModule = ((mod as any).default ?? - mod) as ActionExports; - const actionFunc = actualModule[exportName] as ActionFunc; - await actionFunc(...args); - - process.exit(0); - } catch (error) { - assertError(error); - exitWithError(error); - } - }; -} From 732f3eb50f06e3bfd345135c9a7af75dea9c9831 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 2 Mar 2026 20:51:46 +0100 Subject: [PATCH 10/16] cli: remove changeset Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/fix-prepack-postpack-cjs.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/fix-prepack-postpack-cjs.md diff --git a/.changeset/fix-prepack-postpack-cjs.md b/.changeset/fix-prepack-postpack-cjs.md deleted file mode 100644 index 549841845b..0000000000 --- a/.changeset/fix-prepack-postpack-cjs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Fixed `prepack` and `postpack` commands failing when the dynamic `import()` goes through a CommonJS compatibility layer that doesn't wrap module exports. From 7695dd23d07b631bdf66baf48518a56ee6378475 Mon Sep 17 00:00:00 2001 From: Stephanie Cao Date: Tue, 3 Mar 2026 02:30:06 -0500 Subject: [PATCH 11/16] feat(scaffolder): Create scaffolder mcp action to list all installed template actions (#32765) * add scaffolder mcp action to list all installed template actions Signed-off-by: Stephanie * add changeset Signed-off-by: Stephanie * resolve review comments Signed-off-by: Stephanie * cleanup code Signed-off-by: Stephanie * resolve pr review comments Signed-off-by: Stephanie * remove actionService param in options, as templateActionsRegistry has already been passed in Signed-off-by: Stephanie * remove unnecessary required param check Signed-off-by: Stephanie * type the mcp action output better Signed-off-by: Stephanie * use scaffolderService instead Signed-off-by: Stephanie * revert templateActionRegistry rename, keep actionRegistry as-is Signed-off-by: benjdlambert * clean up list-scaffolder-actions: fix tests, changeset, and description Signed-off-by: benjdlambert --------- Signed-off-by: Stephanie Signed-off-by: benjdlambert Co-authored-by: benjdlambert --- .changeset/tangy-clouds-repeat.md | 5 + app-config.yaml | 1 + .../createListScaffolderActionsAction.test.ts | 164 ++++++++++++++++++ .../createListScaffolderActionsAction.ts | 83 +++++++++ .../scaffolder-backend/src/actions/index.ts | 2 + 5 files changed, 255 insertions(+) create mode 100644 .changeset/tangy-clouds-repeat.md create mode 100644 plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.test.ts create mode 100644 plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.ts diff --git a/.changeset/tangy-clouds-repeat.md b/.changeset/tangy-clouds-repeat.md new file mode 100644 index 0000000000..0999198c21 --- /dev/null +++ b/.changeset/tangy-clouds-repeat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Added a new `list-scaffolder-actions` action that returns all installed scaffolder actions with their schemas and examples diff --git a/app-config.yaml b/app-config.yaml index 08cd0b693c..e88955a39f 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -68,6 +68,7 @@ backend: actions: pluginSources: - catalog + - scaffolder # See README.md in the proxy-backend plugin for information on the configuration format proxy: endpoints: diff --git a/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.test.ts b/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.test.ts new file mode 100644 index 0000000000..145e3deeed --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright 2026 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 { createListScaffolderActionsAction } from './createListScaffolderActionsAction'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils'; +import type { ListActionsResponse } from '@backstage/plugin-scaffolder-common'; + +type ListActionsOutput = { + actions: Array<{ + id: string; + description: string; + schema: { input: object; output: object }; + examples: Array<{ description: string; example: string }>; + }>; +}; + +describe('createListScaffolderActionsAction', () => { + it('should list all scaffolder actions sorted by id with full properties', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + mockScaffolderService.listActions.mockResolvedValue(createMockActions()); + + createListScaffolderActionsAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-actions', + input: {}, + }); + + const output = result.output as ListActionsOutput; + expect(output.actions).toHaveLength(3); + + const actionIds = output.actions.map(a => a.id); + expect(actionIds).toEqual([ + 'catalog:register', + 'debug:log', + 'fetch:template', + ]); + + expect(output.actions[0]).toEqual({ + id: 'catalog:register', + description: 'Registers entities in the catalog', + schema: { + input: { type: 'object' }, + output: { type: 'object' }, + }, + examples: [{ description: 'Basic usage', example: 'register entity' }], + }); + }); + + it('should handle actions without descriptions, schemas, or examples', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + mockScaffolderService.listActions.mockResolvedValue([ + { id: 'minimal-action' }, + ]); + + createListScaffolderActionsAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-actions', + input: {}, + }); + + const output = result.output as ListActionsOutput; + expect(output.actions).toHaveLength(1); + expect(output.actions[0]).toEqual({ + id: 'minimal-action', + description: '', + schema: { input: {}, output: {} }, + examples: [], + }); + }); + + it('should return empty array when no actions are registered', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + mockScaffolderService.listActions.mockResolvedValue([]); + + createListScaffolderActionsAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-actions', + input: {}, + }); + + const output = result.output as ListActionsOutput; + expect(output.actions).toEqual([]); + }); + + it('should propagate errors from the scaffolder service', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + mockScaffolderService.listActions.mockRejectedValue( + new Error('Service unavailable'), + ); + + createListScaffolderActionsAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-actions', + input: {}, + }), + ).rejects.toThrow('Service unavailable'); + }); +}); + +function createMockActions(): ListActionsResponse { + return [ + { + id: 'fetch:template', + description: 'Fetches a template', + schema: { + input: { type: 'object' }, + output: { type: 'object' }, + }, + examples: [], + }, + { + id: 'catalog:register', + description: 'Registers entities in the catalog', + schema: { + input: { type: 'object' }, + output: { type: 'object' }, + }, + examples: [{ description: 'Basic usage', example: 'register entity' }], + }, + { + id: 'debug:log', + description: 'Logs debug information', + schema: { + input: { type: 'object' }, + output: { type: 'object' }, + }, + examples: [], + }, + ]; +} diff --git a/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.ts b/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.ts new file mode 100644 index 0000000000..4450207e16 --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2026 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 { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { ScaffolderService } from '@backstage/plugin-scaffolder-node'; + +export const createListScaffolderActionsAction = ({ + actionsRegistry, + scaffolderService, +}: { + actionsRegistry: ActionsRegistryService; + scaffolderService: ScaffolderService; +}) => { + actionsRegistry.register({ + name: 'list-scaffolder-actions', + title: 'List Scaffolder Actions', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + description: `Lists all installed Scaffolder actions. +Each action includes: +- id: The action identifier +- description: What the action does +- schema: Input and output JSON schemas +- examples: Usage examples when available`, + schema: { + input: z => z.object({}).describe('No input is required'), + output: z => + z.object({ + actions: z.array( + z.object({ + id: z.string(), + description: z.string(), + schema: z.object({ + input: z + .object({}) + .passthrough() + .describe('JSON Schema for input of Action'), + output: z + .object({}) + .passthrough() + .describe('JSON Schema for output of Action'), + }), + examples: z.array(z.any()).optional(), + }), + ), + }), + }, + action: async ({ credentials }) => { + const actions = await scaffolderService.listActions(undefined, { + credentials, + }); + const scaffolderActions = actions.map(action => ({ + id: action.id, + description: action.description ?? '', + schema: { + input: { ...(action.schema?.input ?? {}) }, + output: { ...(action.schema?.output ?? {}) }, + }, + examples: action.examples ?? [], + })); + return { + output: { + actions: scaffolderActions.sort((a, b) => a.id.localeCompare(b.id)), + }, + }; + }, + }); +}; diff --git a/plugins/scaffolder-backend/src/actions/index.ts b/plugins/scaffolder-backend/src/actions/index.ts index 8539ae5b78..94721e94ad 100644 --- a/plugins/scaffolder-backend/src/actions/index.ts +++ b/plugins/scaffolder-backend/src/actions/index.ts @@ -16,10 +16,12 @@ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; import { ScaffolderService } from '@backstage/plugin-scaffolder-node'; import { createDryRunTemplateAction } from './createDryRunTemplateAction'; +import { createListScaffolderActionsAction } from './createListScaffolderActionsAction'; export const createScaffolderActions = (options: { actionsRegistry: ActionsRegistryService; scaffolderService: ScaffolderService; }) => { createDryRunTemplateAction(options); + createListScaffolderActionsAction(options); }; From c9b11eb0cf56d759eed5762dede9e93a13728c0d Mon Sep 17 00:00:00 2001 From: John Collier Date: Tue, 3 Mar 2026 03:38:09 -0500 Subject: [PATCH 12/16] feat(scaffolder): Create basic scaffolder task query action (#32989) * feat(scaffolder): Create basic scaffolder task query action Signed-off-by: John Collier * Add changeset Signed-off-by: John Collier * Fix test Signed-off-by: John Collier * Address review feedback Signed-off-by: John Collier * Update plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: John Collier * Update plugins/scaffolder-backend/src/actions/listScaffolderTaskAction.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: John Collier * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: John Collier * lint Signed-off-by: John Collier * fix conflict markers, remove unused discovery dep, add input validation bounds Signed-off-by: benjdlambert * address PR feedback: fix status enum, add int validation, improve changeset Signed-off-by: benjdlambert --------- Signed-off-by: John Collier Signed-off-by: benjdlambert Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: benjdlambert --- .changeset/free-pigs-itch.md | 5 + .../src/ScaffolderPlugin.ts | 1 + .../scaffolder-backend/src/actions/index.ts | 8 + .../actions/listScaffolderTasksAction.test.ts | 268 ++++++++++++++++++ .../src/actions/listScaffolderTasksAction.ts | 133 +++++++++ 5 files changed, 415 insertions(+) create mode 100644 .changeset/free-pigs-itch.md create mode 100644 plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts create mode 100644 plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts diff --git a/.changeset/free-pigs-itch.md b/.changeset/free-pigs-itch.md new file mode 100644 index 0000000000..c1e22f686e --- /dev/null +++ b/.changeset/free-pigs-itch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Added a new `list-scaffolder-tasks` action that allows querying scaffolder tasks with optional ownership filtering and pagination support diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index 6fdbb3e269..17997da485 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -223,6 +223,7 @@ export const scaffolderPlugin = createBackendPlugin({ createScaffolderActions({ actionsRegistry: actionsRegistryService, scaffolderService, + auth, }); const router = await createRouter({ diff --git a/plugins/scaffolder-backend/src/actions/index.ts b/plugins/scaffolder-backend/src/actions/index.ts index 94721e94ad..4beb39d832 100644 --- a/plugins/scaffolder-backend/src/actions/index.ts +++ b/plugins/scaffolder-backend/src/actions/index.ts @@ -14,6 +14,8 @@ * limitations under the License. */ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { AuthService } from '@backstage/backend-plugin-api'; +import { createListScaffolderTasksAction } from './listScaffolderTasksAction'; import { ScaffolderService } from '@backstage/plugin-scaffolder-node'; import { createDryRunTemplateAction } from './createDryRunTemplateAction'; import { createListScaffolderActionsAction } from './createListScaffolderActionsAction'; @@ -21,7 +23,13 @@ import { createListScaffolderActionsAction } from './createListScaffolderActions export const createScaffolderActions = (options: { actionsRegistry: ActionsRegistryService; scaffolderService: ScaffolderService; + auth: AuthService; }) => { + createListScaffolderTasksAction({ + actionsRegistry: options.actionsRegistry, + auth: options.auth, + scaffolderService: options.scaffolderService, + }); createDryRunTemplateAction(options); createListScaffolderActionsAction(options); }; diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts new file mode 100644 index 0000000000..075ba919f1 --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts @@ -0,0 +1,268 @@ +/* + * 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 { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { mockServices, mockCredentials } from '@backstage/backend-test-utils'; +import { NotAllowedError } from '@backstage/errors'; +import { ScaffolderTask } from '@backstage/plugin-scaffolder-common'; +import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils'; +import { createListScaffolderTasksAction } from './listScaffolderTasksAction'; +import { ListTasksResponse } from '../schema/openapi/generated/models/ListTasksResponse.model'; + +describe('createListScaffolderTasksAction', () => { + it('should list tasks successfully', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + const mockTasks = generateMockTasks(); + + mockScaffolderService.listTasks.mockResolvedValue({ + items: mockTasks.tasks.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })) as ScaffolderTask[], + totalItems: mockTasks.totalTasks ?? 0, + }); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: {}, + }); + + const expectedTasks: ScaffolderTask[] = mockTasks.tasks.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })); + + expect(result.output).toEqual({ + tasks: expectedTasks, + totalTasks: mockTasks.totalTasks ?? 0, + }); + expect(mockScaffolderService.listTasks).toHaveBeenCalledWith( + { createdBy: undefined, limit: undefined, offset: undefined }, + expect.objectContaining({ credentials: expect.anything() }), + ); + }); + + it('should pass limit and offset through to the API and return paginated results', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + const paginatedTasks = [ + { + id: 'task-2', + spec: {}, + status: 'completed', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:01:00Z', + }, + { + id: 'task-3', + spec: {}, + status: 'processing', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:02:00Z', + }, + ]; + + mockScaffolderService.listTasks.mockResolvedValue({ + items: paginatedTasks as ScaffolderTask[], + totalItems: 10, + }); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: { limit: 2, offset: 1 }, + }); + + expect(mockScaffolderService.listTasks).toHaveBeenCalledWith( + { createdBy: undefined, limit: 2, offset: 1 }, + expect.objectContaining({ credentials: expect.anything() }), + ); + + const expectedTasks = paginatedTasks.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })); + + expect(result.output).toEqual({ + tasks: expectedTasks, + totalTasks: 10, + }); + }); + + it('should throw an error if the service call fails', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + + mockScaffolderService.listTasks.mockRejectedValue( + new Error('Internal Server Error'), + ); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: {}, + }), + ).rejects.toThrow('Internal Server Error'); + }); + + it('should use createdBy filter when owned is true with user identity', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + const mockTasks = generateMockTasks(); + + mockAuth.isPrincipal.mockImplementation( + (creds, type) => + type === 'user' && + (creds?.principal as { type?: string })?.type === 'user' && + typeof (creds.principal as { userEntityRef?: string }).userEntityRef === + 'string', + ); + mockScaffolderService.listTasks.mockResolvedValue({ + items: mockTasks.tasks.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })) as ScaffolderTask[], + totalItems: mockTasks.totalTasks ?? 0, + }); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: { owned: true }, + credentials: mockCredentials.user('user:default/alice'), + }); + + expect(mockScaffolderService.listTasks).toHaveBeenCalledWith( + { + createdBy: 'user:default/alice', + limit: undefined, + offset: undefined, + }, + expect.objectContaining({ credentials: expect.anything() }), + ); + }); + + it('should throw NotAllowedError when owned is true without user identity', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + + mockAuth.isPrincipal.mockReturnValue(false); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: { owned: true }, + credentials: mockCredentials.service(), + }), + ).rejects.toThrow(NotAllowedError); + + expect(mockScaffolderService.listTasks).not.toHaveBeenCalled(); + }); +}); + +// Return a mocked ListTasksResponse that contains a number of different mocked tasks +function generateMockTasks(): ListTasksResponse { + return { + tasks: [ + { + id: 'task-1', + spec: {}, + status: 'completed', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:01:00Z', + createdBy: 'user:default/guest', + }, + { + id: 'task-2', + spec: {}, + status: 'completed', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:01:00Z', + createdBy: 'user:default/guest', + }, + { + id: 'task-3', + spec: {}, + status: 'processing', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:02:00Z', + createdBy: 'user:default/admin', + }, + { + id: 'task-4', + spec: {}, + status: 'failed', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:02:00Z', + createdBy: 'user:default/admin', + }, + { + id: 'task-5', + spec: {}, + status: 'cancelled', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:02:00Z', + createdBy: 'user:default/admin', + }, + ], + totalTasks: 5, + }; +} diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts new file mode 100644 index 0000000000..70977c9d12 --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts @@ -0,0 +1,133 @@ +/* + * 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 { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { AuthService } from '@backstage/backend-plugin-api'; +import { NotAllowedError } from '@backstage/errors'; +import { ScaffolderService } from '@backstage/plugin-scaffolder-node'; + +export const createListScaffolderTasksAction = ({ + actionsRegistry, + auth, + scaffolderService, +}: { + actionsRegistry: ActionsRegistryService; + auth: AuthService; + scaffolderService: ScaffolderService; +}) => { + actionsRegistry.register({ + name: 'list-scaffolder-tasks', + title: 'List Scaffolder Tasks', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + description: ` +This allows you to list scaffolder tasks that have been created. +Each task has a unique id, specification, and status (one of open, processing, completed, failed, cancelled, skipped). +Each task includes a timestamp for when it was created, and an optional last heartbeat timestamp indicating the most recent activity. +Set owned to true to return only tasks created by the current user; omit or set to false for all tasks the credentials can see. +Pagination is supported via limit and offset. + `, + schema: { + input: z => + z.object({ + owned: z + .boolean() + .optional() + .default(false) + .describe( + 'If true, return only tasks created by the current user. Requires a user identity.', + ), + limit: z + .number() + .int() + .min(1) + .max(1000) + .describe('The maximum number of tasks to return for pagination') + .optional(), + offset: z + .number() + .int() + .min(0) + .describe('The offset to start from for pagination') + .optional(), + }), + output: z => + z + .object({ + tasks: z + .array( + z.object({ + id: z.string().describe('The task identifier'), + spec: z.unknown().describe('The task specification'), + status: z + .string() + .describe( + 'Task status: open, processing, completed, failed, cancelled, or skipped', + ), + createdAt: z + .string() + .describe('Timestamp when the task was created'), + lastHeartbeatAt: z + .string() + .optional() + .describe('Timestamp of the last heartbeat'), + }), + ) + .describe('The list of scaffolder tasks'), + totalTasks: z + .number() + .describe('Total number of tasks matching the filter'), + }) + .describe('Object containing a tasks array and totalTasks count'), + }, + action: async ({ input, credentials }) => { + if (input.owned && !auth.isPrincipal(credentials, 'user')) { + throw new NotAllowedError( + 'Filtering by owned tasks requires a user identity.', + ); + } + + const createdBy = + input.owned && auth.isPrincipal(credentials, 'user') + ? credentials.principal.userEntityRef + : undefined; + + const { items, totalItems } = await scaffolderService.listTasks( + { + createdBy, + limit: input.limit, + offset: input.offset, + }, + { credentials }, + ); + + return { + output: { + tasks: items.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })), + totalTasks: totalItems, + }, + }; + }, + }); +}; From 8ab2d7c3a2bf2a4096046dacefa3c9a000ba45de Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 3 Mar 2026 08:41:06 +0000 Subject: [PATCH 13/16] Update spotify.css Signed-off-by: Charles de Dreuille --- .storybook/themes/spotify.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.storybook/themes/spotify.css b/.storybook/themes/spotify.css index 3aa7580748..3cad0fba10 100644 --- a/.storybook/themes/spotify.css +++ b/.storybook/themes/spotify.css @@ -185,8 +185,9 @@ .bui-PluginHeaderToolbarWrapper { padding: 0; - border: none; height: 32px; + border: none; + background: none; } .bui-PluginHeaderTabsWrapper { @@ -194,6 +195,7 @@ padding: 0; margin-left: -8px; border: none; + background: none; } .bui-Input { From 0c1726a5d157fcde0059710b2bc8d6ca9bcb7e5f Mon Sep 17 00:00:00 2001 From: Jellyfrog Date: Tue, 3 Mar 2026 10:25:04 +0100 Subject: [PATCH 14/16] feat(scaffolder-backend-module-gitlab): add gitlab:group:access action (#32528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(scaffolder-backend-module-gitlab): add gitlab:group:access action Add a new scaffolder action to add or remove users and groups as members of GitLab groups. The action supports specifying access levels (Guest, Reporter, Developer, Maintainer, Owner) when adding members and includes dry-run support. Signed-off-by: Jellyfrog * Update plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts Signed-off-by: Fredrik Adelöw * Update plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Jellyfrog * Update plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Jellyfrog * Update gitlabGroupAccessAction to use parseRepoHost and conditionally resolve access level Use parseRepoHost instead of parseRepoUrl since only the host is needed, and skip resolveAccessLevel for remove actions where access level is unused. Signed-off-by: Jellyfrog --------- Signed-off-by: Jellyfrog Signed-off-by: Fredrik Adelöw Co-authored-by: Fredrik Adelöw Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .changeset/bumpy-keys-pay.md | 5 + .../report.api.md | 22 + .../gitlabGroupAccessAction.examples.test.ts | 168 ++++ .../gitlabGroupAccessAction.examples.ts | 110 +++ .../actions/gitlabGroupAccessAction.test.ts | 718 ++++++++++++++++++ .../src/actions/gitlabGroupAccessAction.ts | 249 ++++++ .../src/actions/index.ts | 1 + .../src/module.ts | 2 + 8 files changed, 1275 insertions(+) create mode 100644 .changeset/bumpy-keys-pay.md create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.test.ts create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.ts create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts diff --git a/.changeset/bumpy-keys-pay.md b/.changeset/bumpy-keys-pay.md new file mode 100644 index 0000000000..fdd698fd7b --- /dev/null +++ b/.changeset/bumpy-keys-pay.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Added new `gitlab:group:access` scaffolder action to add or remove users and groups as members of GitLab groups. The action supports specifying members via `userIds` and/or `groupIds` array parameters, configurable access levels (Guest, Reporter, Developer, Maintainer, Owner), and defaults to the 'add' action when not specified. diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index 519e3938e2..cbe31b02f3 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -8,6 +8,28 @@ import { Config } from '@backstage/config'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +// @public +export const createGitlabGroupAccessAction: (options: { + integrations: ScmIntegrationRegistry; +}) => TemplateAction< + { + repoUrl: string; + path: string | number; + token?: string | undefined; + userIds?: number[] | undefined; + groupIds?: number[] | undefined; + action?: 'add' | 'remove' | undefined; + accessLevel?: string | number | undefined; + }, + { + userIds?: number[] | undefined; + groupIds?: number[] | undefined; + path?: string | number | undefined; + accessLevel?: number | undefined; + }, + 'v2' +>; + // @public export const createGitlabGroupEnsureExistsAction: (options: { integrations: ScmIntegrationRegistry; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.test.ts new file mode 100644 index 0000000000..3f15816e68 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2026 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 { ScmIntegrations } from '@backstage/integration'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import yaml from 'yaml'; +import { createGitlabGroupAccessAction } from './gitlabGroupAccessAction'; +import { examples } from './gitlabGroupAccessAction.examples'; +import { mockServices } from '@backstage/backend-test-utils'; + +const mockGitlabClient = { + GroupMembers: { + add: jest.fn(), + remove: jest.fn(), + }, + Groups: { + share: jest.fn(), + unshare: jest.fn(), + }, +}; + +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('gitlab:group:access examples', () => { + const mockContext = createMockActionContext(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const config = mockServices.rootConfig({ + data: { + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'tokenlols', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + ], + }, + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = createGitlabGroupAccessAction({ integrations }); + + it(`Should ${examples[0].description}`, async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: yaml.parse(examples[0].example).steps[0].input, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 789, + 30, + ); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it(`Should ${examples[1].description}`, async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: yaml.parse(examples[1].example).steps[0].input, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 'group1', + 456, + 30, + ); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 'group1'); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it(`Should ${examples[2].description}`, async () => { + mockGitlabClient.GroupMembers.remove.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: yaml.parse(examples[2].example).steps[0].input, + }); + + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 456); + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 789); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).not.toHaveBeenCalledWith( + 'accessLevel', + expect.anything(), + ); + }); + + it(`Should ${examples[3].description}`, async () => { + mockGitlabClient.Groups.share.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: yaml.parse(examples[3].example).steps[0].input, + }); + + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 456, + 30, + {}, + ); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it(`Should ${examples[4].description}`, async () => { + mockGitlabClient.Groups.unshare.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: yaml.parse(examples[4].example).steps[0].input, + }); + + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledWith(123, 456, {}); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).not.toHaveBeenCalledWith( + 'accessLevel', + expect.anything(), + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.ts new file mode 100644 index 0000000000..8ff69f3b9d --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2026 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 yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'Add users to a group using numeric group ID', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabGroupAccess', + name: 'Add Users to Group', + action: 'gitlab:group:access', + input: { + repoUrl: 'gitlab.com', + path: 123, + userIds: [456, 789], + accessLevel: 30, + }, + }, + ], + }), + }, + { + description: 'Add users to a group using string path', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabGroupAccess', + name: 'Add Users to Group', + action: 'gitlab:group:access', + input: { + repoUrl: 'gitlab.com', + path: 'group1', + userIds: [456], + accessLevel: 'developer', + }, + }, + ], + }), + }, + { + description: 'Remove users from a group', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabGroupAccess', + name: 'Remove Users from Group', + action: 'gitlab:group:access', + input: { + repoUrl: 'gitlab.com', + path: 123, + userIds: [456, 789], + action: 'remove', + }, + }, + ], + }), + }, + { + description: 'Share a group with another group', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabGroupAccess', + name: 'Share Group', + action: 'gitlab:group:access', + input: { + repoUrl: 'gitlab.com', + path: 123, + groupIds: [456], + accessLevel: 30, + }, + }, + ], + }), + }, + { + description: 'Unshare a group', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabGroupAccess', + name: 'Unshare Group', + action: 'gitlab:group:access', + input: { + repoUrl: 'gitlab.com', + path: 123, + groupIds: [456], + action: 'remove', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts new file mode 100644 index 0000000000..79edc8f457 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts @@ -0,0 +1,718 @@ +/* + * Copyright 2026 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 { ScmIntegrations } from '@backstage/integration'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { createGitlabGroupAccessAction } from './gitlabGroupAccessAction'; +import { getClient } from '../util'; +import { mockServices } from '@backstage/backend-test-utils'; + +const mockGitlabClient = { + GroupMembers: { + add: jest.fn(), + edit: jest.fn(), + remove: jest.fn(), + }, + Groups: { + share: jest.fn(), + unshare: jest.fn(), + }, +}; + +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +jest.mock('../util', () => ({ + getClient: jest.fn().mockImplementation(() => mockGitlabClient), + parseRepoHost: (repoUrl: string) => repoUrl, +})); + +describe('gitlab:group:access', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const config = mockServices.rootConfig({ + data: { + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'tokenlols', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + ], + }, + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = createGitlabGroupAccessAction({ integrations }); + + const mockContext = createMockActionContext(); + + // User tests + it('should add a single user to a group with the specified access level', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({ + id: 1, + user_id: 456, + group_id: 123, + access_level: 30, + }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should add multiple users to a group with the specified access level', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456, 789, 101], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledTimes(3); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 789, + 30, + ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 101, + 30, + ); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789, 101]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should default to add action when action is not specified', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockGitlabClient.GroupMembers.remove).not.toHaveBeenCalled(); + }); + + it('should remove a single user from a group', async () => { + mockGitlabClient.GroupMembers.remove.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + action: 'remove', + }, + }); + + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 456); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).not.toHaveBeenCalledWith( + 'accessLevel', + expect.anything(), + ); + }); + + it('should remove multiple users from a group', async () => { + mockGitlabClient.GroupMembers.remove.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456, 789], + action: 'remove', + }, + }); + + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 456); + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 789); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + }); + + it('should default to accessLevel 30 (Developer) when not specified', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should not call API on dryRun for add action', async () => { + await action.handler({ + ...mockContext, + isDryRun: true, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456, 789], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).not.toHaveBeenCalled(); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should not call API on dryRun for remove action', async () => { + await action.handler({ + ...mockContext, + isDryRun: true, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + action: 'remove', + }, + }); + + expect(mockGitlabClient.GroupMembers.remove).not.toHaveBeenCalled(); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).not.toHaveBeenCalledWith( + 'accessLevel', + expect.anything(), + ); + }); + + it('should use the token from the integration config when none is provided', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + }, + }); + + expect(getClient).toHaveBeenCalledWith( + expect.not.objectContaining({ + token: expect.anything(), + }), + ); + }); + + it('should use a provided token for authentication', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + token: 'mysecrettoken', + }, + }); + + expect(getClient).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'mysecrettoken', + }), + ); + }); + + it('should add users as Guest (accessLevel 10)', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 10, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 10, + ); + }); + + it('should add users as Maintainer (accessLevel 40)', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 40, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 40, + ); + }); + + it('should add users as Owner (accessLevel 50)', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 50, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 50, + ); + }); + + it('should accept string accessLevel "developer"', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 'developer', + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should accept string accessLevel "maintainer" (case insensitive)', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 'MAINTAINER', + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 40, + ); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 40); + }); + + it('should throw an error for invalid string accessLevel', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 'invalid_level', + }, + }), + ).rejects.toThrow('Invalid access level: "invalid_level"'); + + expect(mockGitlabClient.GroupMembers.add).not.toHaveBeenCalled(); + }); + + it('should handle 409 conflict by editing existing user member', async () => { + mockGitlabClient.GroupMembers.add.mockRejectedValue({ + cause: { response: { status: 409 } }, + }); + mockGitlabClient.GroupMembers.edit.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockGitlabClient.GroupMembers.edit).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + }); + + // Group sharing tests + it('should share a single group with another group', async () => { + mockGitlabClient.Groups.share.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 456, + 30, + {}, + ); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should share multiple groups with a group', async () => { + mockGitlabClient.Groups.share.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456, 789], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.Groups.share).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 456, + 30, + {}, + ); + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 789, + 30, + {}, + ); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should unshare groups from a group', async () => { + mockGitlabClient.Groups.unshare.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456, 789], + action: 'remove', + }, + }); + + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledWith(123, 456, {}); + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledWith(123, 789, {}); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + }); + + it('should handle 409 conflict for group sharing by re-sharing', async () => { + mockGitlabClient.Groups.share.mockRejectedValueOnce({ + cause: { response: { status: 409 } }, + }); + mockGitlabClient.Groups.unshare.mockResolvedValue(undefined); + mockGitlabClient.Groups.share.mockResolvedValueOnce({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.Groups.share).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledWith(123, 456, {}); + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456]); + }); + + it('should not call API on dryRun for group sharing', async () => { + await action.handler({ + ...mockContext, + isDryRun: true, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456, 789], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.Groups.share).not.toHaveBeenCalled(); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + // Mixed mode tests + it('should add users and share groups simultaneously', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + mockGitlabClient.Groups.share.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456, 789], + groupIds: [101, 102], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 789, + 30, + ); + + expect(mockGitlabClient.Groups.share).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 101, + 30, + {}, + ); + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 102, + 30, + {}, + ); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [101, 102]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should remove users and unshare groups simultaneously', async () => { + mockGitlabClient.GroupMembers.remove.mockResolvedValue(undefined); + mockGitlabClient.Groups.unshare.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + groupIds: [789], + action: 'remove', + }, + }); + + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 456); + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledWith(123, 789, {}); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + }); + + // Validation tests + it('should throw an error when neither userIds nor groupIds provided', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + }, + }), + ).rejects.toThrow( + 'At least one of userIds or groupIds must be provided and non-empty', + ); + + expect(mockGitlabClient.GroupMembers.add).not.toHaveBeenCalled(); + expect(mockGitlabClient.Groups.share).not.toHaveBeenCalled(); + }); + + it('should throw an error when userIds and groupIds are both empty arrays', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [], + groupIds: [], + }, + }), + ).rejects.toThrow( + 'At least one of userIds or groupIds must be provided and non-empty', + ); + + expect(mockGitlabClient.GroupMembers.add).not.toHaveBeenCalled(); + expect(mockGitlabClient.Groups.share).not.toHaveBeenCalled(); + }); + + it('should not output userIds when only groupIds are provided', async () => { + mockGitlabClient.Groups.share.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456], + accessLevel: 30, + }, + }); + + expect(mockContext.output).not.toHaveBeenCalledWith( + 'userIds', + expect.anything(), + ); + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456]); + }); + + it('should not output groupIds when only userIds are provided', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + }, + }); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).not.toHaveBeenCalledWith( + 'groupIds', + expect.anything(), + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts new file mode 100644 index 0000000000..f61b850667 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts @@ -0,0 +1,249 @@ +/* + * Copyright 2026 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 { InputError } from '@backstage/errors'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import { getClient, parseRepoHost } from '../util'; +import { examples } from './gitlabGroupAccessAction.examples'; + +const accessLevelMapping: Record = { + no_access: 0, + minimal_access: 5, + guest: 10, + planner: 15, + reporter: 20, + developer: 30, + maintainer: 40, + owner: 50, +}; + +function resolveAccessLevel(level: string | number): number { + if (typeof level === 'number') return level; + const resolved = accessLevelMapping[level.toLocaleLowerCase('en-US')]; + if (resolved === undefined) { + throw new InputError( + `Invalid access level: "${level}". Valid values are: ${Object.keys( + accessLevelMapping, + ).join(', ')} or a numeric GitLab access level`, + ); + } + return resolved; +} + +/** + * Creates a `gitlab:group:access` Scaffolder action. + * + * @public + */ +export const createGitlabGroupAccessAction = (options: { + integrations: ScmIntegrationRegistry; +}) => { + const { integrations } = options; + + return createTemplateAction({ + id: 'gitlab:group:access', + description: 'Adds or removes users and groups from a GitLab group', + supportsDryRun: true, + examples, + schema: { + input: { + repoUrl: z => + z.string({ + description: + "The host of the GitLab instance, for example 'gitlab.com' or 'gitlab.my-company.com'.", + }), + token: z => + z + .string({ + description: 'The token to use for authorization to GitLab', + }) + .optional(), + path: z => + z.union([z.number(), z.string()], { + description: + 'The ID or path of the group to add/remove members from', + }), + userIds: z => + z + .array(z.number(), { + description: 'The IDs of the users to add/remove', + }) + .optional(), + groupIds: z => + z + .array(z.number(), { + description: + 'The IDs of the groups to share with or unshare from the target group', + }) + .optional(), + action: z => + z + .enum(['add', 'remove'], { + description: + 'The action to perform: add or remove the members. Defaults to "add".', + }) + .default('add') + .optional(), + accessLevel: z => + z + .union([z.number(), z.string()], { + description: + 'The access level for the members. Can be a number (0=No access, 5=Minimal access, 10=Guest, 15=Planner, 20=Reporter, 30=Developer, 40=Maintainer, 50=Owner) or a string (e.g., "guest", "developer"). Defaults to 30 (Developer).', + }) + .default(30) + .optional(), + }, + output: { + userIds: z => + z + .array(z.number(), { + description: 'The IDs of the users that were added or removed', + }) + .optional(), + groupIds: z => + z + .array(z.number(), { + description: + 'The IDs of the groups that were shared with or unshared from', + }) + .optional(), + path: z => + z + .union([z.number(), z.string()], { + description: + 'The ID or path of the group the members were added to or removed from', + }) + .optional(), + accessLevel: z => + z + .number({ + description: + 'The access level granted to the members (only for add action)', + }) + .optional(), + }, + }, + async handler(ctx) { + const { + token, + repoUrl, + path, + userIds = [], + groupIds = [], + accessLevel: rawAccessLevel = 30, + action = 'add', + } = ctx.input; + + if (userIds.length === 0 && groupIds.length === 0) { + throw new InputError( + 'At least one of userIds or groupIds must be provided and non-empty', + ); + } + + const accessLevel = + action === 'add' ? resolveAccessLevel(rawAccessLevel) : 0; + + if (ctx.isDryRun) { + if (userIds.length > 0) { + ctx.output('userIds', userIds); + } + if (groupIds.length > 0) { + ctx.output('groupIds', groupIds); + } + ctx.output('path', path); + if (action === 'add') { + ctx.output('accessLevel', accessLevel); + } + return; + } + + const host = parseRepoHost(repoUrl); + + const api = getClient({ host, integrations, token }); + + // Process users + for (const userId of userIds) { + ctx.logger.info( + `${action === 'add' ? 'Adding' : 'Removing'} user ${userId} ${ + action === 'add' ? 'to' : 'from' + } group ${path}`, + ); + await ctx.checkpoint({ + key: `gitlab.group.member.user.${action}.${path}.${userId}`, + fn: async () => { + if (action === 'add') { + try { + await api.GroupMembers.add(path, userId, accessLevel); + } catch (error: any) { + // If member already exists, try to edit instead + if (error.cause?.response?.status === 409) { + await api.GroupMembers.edit(path, userId, accessLevel); + return; + } + throw error; + } + } else { + await api.GroupMembers.remove(path, userId); + } + }, + }); + } + + // Process groups + for (const sharedGroupId of groupIds) { + ctx.logger.info( + `${action === 'add' ? 'Adding' : 'Removing'} group ${sharedGroupId} ${ + action === 'add' ? 'to' : 'from' + } group ${path}`, + ); + await ctx.checkpoint({ + key: `gitlab.group.member.group.${action}.${path}.${sharedGroupId}`, + fn: async () => { + if (action === 'add') { + try { + await api.Groups.share(path, sharedGroupId, accessLevel, {}); + } catch (error: any) { + // If group is already shared, unshare and re-share + if (error.cause?.response?.status === 409) { + await api.Groups.unshare(path, sharedGroupId, {}); + await api.Groups.share(path, sharedGroupId, accessLevel, {}); + return; + } + throw error; + } + } else { + await api.Groups.unshare(path, sharedGroupId, {}); + } + }, + }); + } + + ctx.output('path', path); + + if (userIds.length > 0) { + ctx.output('userIds', userIds); + } + if (groupIds.length > 0) { + ctx.output('groupIds', groupIds); + } + + if (action === 'add') { + ctx.output('accessLevel', accessLevel); + } + }, + }); +}; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts index 2a74e78a41..17364e9916 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts @@ -15,6 +15,7 @@ */ export * from './gitlab'; export * from './gitlabGroupEnsureExists'; +export * from './gitlabGroupAccessAction'; export * from './gitlabIssueCreate'; export * from './gitlabIssueEdit'; export * from './gitlabMergeRequest'; diff --git a/plugins/scaffolder-backend-module-gitlab/src/module.ts b/plugins/scaffolder-backend-module-gitlab/src/module.ts index 61525753a2..653ce6e377 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/module.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/module.ts @@ -21,6 +21,7 @@ import { ScmIntegrations } from '@backstage/integration'; import { scaffolderAutocompleteExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; import { createGitlabGroupEnsureExistsAction, + createGitlabGroupAccessAction, createGitlabIssueAction, createGitlabProjectAccessTokenAction, createGitlabProjectDeployTokenAction, @@ -55,6 +56,7 @@ export const gitlabModule = createBackendModule({ scaffolder.addActions( createGitlabGroupEnsureExistsAction({ integrations }), + createGitlabGroupAccessAction({ integrations }), createGitlabProjectMigrateAction({ integrations }), createGitlabIssueAction({ integrations }), createGitlabProjectAccessTokenAction({ integrations }), From 6c6a41c4d168d9e82e01fe99f29e86540995877c Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Tue, 3 Mar 2026 11:48:40 +0100 Subject: [PATCH 15/16] fix(scaffolder-backend-module-gitlab): update GroupMembers.add for gitbeaker v43 (#33091) @gitbeaker/core v43 changed ResourceMembers.add signature to accept accessLevel as the second arg and userId in an options object. Signed-off-by: benjdlambert --- .../package.json | 1 + .../gitlabGroupAccessAction.examples.test.ts | 18 ++- .../actions/gitlabGroupAccessAction.test.ts | 112 +++++++----------- .../src/actions/gitlabGroupAccessAction.ts | 18 ++- yarn.lock | 1 + 5 files changed, 65 insertions(+), 85 deletions(-) diff --git a/plugins/scaffolder-backend-module-gitlab/package.json b/plugins/scaffolder-backend-module-gitlab/package.json index 558de71045..ac0cf1b9ad 100644 --- a/plugins/scaffolder-backend-module-gitlab/package.json +++ b/plugins/scaffolder-backend-module-gitlab/package.json @@ -50,6 +50,7 @@ "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", + "@gitbeaker/core": "^43.8.0", "@gitbeaker/requester-utils": "^43.8.0", "@gitbeaker/rest": "^43.8.0", "luxon": "^3.0.0", diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.test.ts index 3f15816e68..f49986a344 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.test.ts @@ -73,16 +73,12 @@ describe('gitlab:group:access examples', () => { }); expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledTimes(2); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 30, - ); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 789, - 30, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 456, + }); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 789, + }); expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789]); expect(mockContext.output).toHaveBeenCalledWith('path', 123); @@ -99,8 +95,8 @@ describe('gitlab:group:access examples', () => { expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( 'group1', - 456, 30, + { userId: 456 }, ); expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts index 79edc8f457..04491c3c4e 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts @@ -88,11 +88,9 @@ describe('gitlab:group:access', () => { }, }); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 30, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 456, + }); expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); expect(mockContext.output).toHaveBeenCalledWith('path', 123); @@ -113,21 +111,15 @@ describe('gitlab:group:access', () => { }); expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledTimes(3); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 30, - ); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 789, - 30, - ); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 101, - 30, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 456, + }); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 789, + }); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 101, + }); expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789, 101]); expect(mockContext.output).toHaveBeenCalledWith('path', 123); @@ -147,11 +139,9 @@ describe('gitlab:group:access', () => { }, }); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 30, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 456, + }); expect(mockGitlabClient.GroupMembers.remove).not.toHaveBeenCalled(); }); @@ -211,11 +201,9 @@ describe('gitlab:group:access', () => { }, }); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 30, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 456, + }); expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); }); @@ -314,11 +302,9 @@ describe('gitlab:group:access', () => { }, }); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 10, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 10, { + userId: 456, + }); }); it('should add users as Maintainer (accessLevel 40)', async () => { @@ -334,11 +320,9 @@ describe('gitlab:group:access', () => { }, }); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 40, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 40, { + userId: 456, + }); }); it('should add users as Owner (accessLevel 50)', async () => { @@ -354,11 +338,9 @@ describe('gitlab:group:access', () => { }, }); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 50, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 50, { + userId: 456, + }); }); it('should accept string accessLevel "developer"', async () => { @@ -374,11 +356,9 @@ describe('gitlab:group:access', () => { }, }); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 30, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 456, + }); expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); }); @@ -395,11 +375,9 @@ describe('gitlab:group:access', () => { }, }); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 40, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 40, { + userId: 456, + }); expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 40); }); @@ -435,11 +413,9 @@ describe('gitlab:group:access', () => { }, }); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 30, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 456, + }); expect(mockGitlabClient.GroupMembers.edit).toHaveBeenCalledWith( 123, 456, @@ -585,16 +561,12 @@ describe('gitlab:group:access', () => { }); expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledTimes(2); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 456, - 30, - ); - expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( - 123, - 789, - 30, - ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 456, + }); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 789, + }); expect(mockGitlabClient.Groups.share).toHaveBeenCalledTimes(2); expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts index f61b850667..7ad295880e 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts @@ -17,9 +17,12 @@ import { InputError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import { AccessLevel } from '@gitbeaker/core'; import { getClient, parseRepoHost } from '../util'; import { examples } from './gitlabGroupAccessAction.examples'; +type NonAdminAccessLevel = Exclude; + const accessLevelMapping: Record = { no_access: 0, minimal_access: 5, @@ -154,8 +157,7 @@ export const createGitlabGroupAccessAction = (options: { ); } - const accessLevel = - action === 'add' ? resolveAccessLevel(rawAccessLevel) : 0; + const accessLevel = resolveAccessLevel(rawAccessLevel); if (ctx.isDryRun) { if (userIds.length > 0) { @@ -187,11 +189,19 @@ export const createGitlabGroupAccessAction = (options: { fn: async () => { if (action === 'add') { try { - await api.GroupMembers.add(path, userId, accessLevel); + await api.GroupMembers.add( + path, + accessLevel as NonAdminAccessLevel, + { userId }, + ); } catch (error: any) { // If member already exists, try to edit instead if (error.cause?.response?.status === 409) { - await api.GroupMembers.edit(path, userId, accessLevel); + await api.GroupMembers.edit( + path, + userId, + accessLevel as NonAdminAccessLevel, + ); return; } throw error; diff --git a/yarn.lock b/yarn.lock index 3956198af0..634d7ade79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6881,6 +6881,7 @@ __metadata: "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/plugin-scaffolder-node-test-utils": "workspace:^" + "@gitbeaker/core": "npm:^43.8.0" "@gitbeaker/requester-utils": "npm:^43.8.0" "@gitbeaker/rest": "npm:^43.8.0" luxon: "npm:^3.0.0" From aa59ab0edee372daa1e4037af9b38c0979c6101c Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Tue, 3 Mar 2026 14:41:57 +0100 Subject: [PATCH 16/16] chore: add design-system-maintainers as code owners for .storybook and docs-ui These directories were previously only covered by the catch-all rule, requiring review from @backstage/maintainers. Since they fall under the Design System project area scope, assign them to @backstage/design-system-maintainers. Signed-off-by: Johan Persson --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 725c6c676b..7e5cc36bdf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -12,8 +12,10 @@ yarn.lock @backstage/maintainers @backst /.github @backstage/operations-maintainers /.github/vale @backstage/maintainers @backstage/documentation-maintainers /.patches @backstage/operations-maintainers +/.storybook @backstage/design-system-maintainers /beps/0001-notifications-system @backstage/maintainers @backstage/notifications-maintainers /docs @backstage/maintainers @backstage/documentation-maintainers +/docs-ui @backstage/design-system-maintainers /docs/assets/search @backstage/search-maintainers /docs/auth @backstage/auth-maintainers /docs/backend-system @backstage/framework-maintainers