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. 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/.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/.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/.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/.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/.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/.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 diff --git a/.storybook/themes/spotify.css b/.storybook/themes/spotify.css index 89b71c48d3..3cad0fba10 100644 --- a/.storybook/themes/spotify.css +++ b/.storybook/themes/spotify.css @@ -183,27 +183,19 @@ 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; + height: 32px; border: none; + background: none; } - .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; + background: none; } .bui-Input { 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/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 fd94cb5db6..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'; @@ -215,20 +214,16 @@ 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/prepack'), }, }); 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/postpack'), }, }); diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index df7a3aa4af..8ea8dc2caf 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -33,13 +33,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; }[]; @@ -90,7 +91,6 @@ export interface Config { endpoint?: string; /** * Optional credential to use for Azure Active Directory authentication. - * @deepVisibility secret */ aadCredential?: { /** @@ -105,6 +105,7 @@ export interface Config { /** * The client secret for the Azure AD application. + * @visibility secret */ clientSecret: string; }; diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index bcaf676411..f313bda651 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 79ca319f48..f016ef7674 100644 --- a/packages/ui/src/components/PluginHeader/PluginHeader.module.css +++ b/packages/ui/src/components/PluginHeader/PluginHeader.module.css @@ -17,25 +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; - z-index: 0; - } - } - .bui-PluginHeaderToolbarWrapper { - position: relative; - z-index: 1; display: flex; flex-direction: row; align-items: center; @@ -75,10 +57,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 bb3f9ce269..ac5f04a011 100644 --- a/packages/ui/src/components/PluginHeader/PluginHeader.tsx +++ b/packages/ui/src/components/PluginHeader/PluginHeader.tsx @@ -15,7 +15,6 @@ */ import type { PluginHeaderProps } from './types'; -import { PluginHeaderToolbar } from './PluginHeaderToolbar'; import { Tabs, TabList, Tab } from '../Tabs'; import { useDefinition } from '../../hooks/useDefinition'; import { PluginHeaderDefinition } from './definition'; @@ -23,6 +22,9 @@ import { type NavigateOptions } from 'react-router-dom'; import { useRef } from 'react'; import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect'; import { Box } from '../Box'; +import { Link } from 'react-aria-components'; +import { RiShapesLine } from '@remixicon/react'; +import { Text } from '../Text'; declare module 'react-aria-components' { interface RouterConfig { @@ -50,6 +52,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; @@ -83,17 +88,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 23bf797ec9..0000000000 --- a/packages/ui/src/components/PluginHeader/PluginHeaderToolbar.tsx +++ /dev/null @@ -1,67 +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'; -import { Box } from '../Box'; - -/** - * 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 {} diff --git a/packages/ui/src/components/Tabs/Tabs.stories.tsx b/packages/ui/src/components/Tabs/Tabs.stories.tsx index 2a02bedc8c..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" @@ -454,6 +456,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: '', @@ -488,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. 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) { 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%); 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/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..f49986a344 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.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 { 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, 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); + 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', + 30, + { userId: 456 }, + ); + + 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..04491c3c4e --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts @@ -0,0 +1,690 @@ +/* + * 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, 30, { + userId: 456, + }); + + 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, 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); + 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, 30, { + userId: 456, + }); + 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, 30, { + userId: 456, + }); + 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, 10, { + userId: 456, + }); + }); + + 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, 40, { + userId: 456, + }); + }); + + 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, 50, { + userId: 456, + }); + }); + + 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, 30, { + userId: 456, + }); + 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, 40, { + userId: 456, + }); + 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, 30, { + userId: 456, + }); + 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, 30, { + userId: 456, + }); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith(123, 30, { + userId: 789, + }); + + 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..7ad295880e --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts @@ -0,0 +1,259 @@ +/* + * 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 { 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, + 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 = resolveAccessLevel(rawAccessLevel); + + 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, + 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 as NonAdminAccessLevel, + ); + 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 }), 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/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..4beb39d832 100644 --- a/plugins/scaffolder-backend/src/actions/index.ts +++ b/plugins/scaffolder-backend/src/actions/index.ts @@ -14,12 +14,22 @@ * 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'; 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, + }, + }; + }, + }); +}; 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"