Merge branch 'master' into ui/searchfield-textfield-bg-focus

# Conflicts:
#	packages/ui/src/components/PluginHeader/PluginHeader.module.css
#	packages/ui/src/components/PluginHeader/PluginHeader.tsx
#	packages/ui/src/components/PluginHeader/PluginHeaderToolbar.tsx
This commit is contained in:
Charles de Dreuille
2026-03-03 14:16:54 +00:00
38 changed files with 2144 additions and 230 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
Fixed neutral-1 hover & pressed state in light mode.
+5
View File
@@ -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.
+7
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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.
+7
View File
@@ -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
+5
View File
@@ -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
+2
View File
@@ -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
+9 -17
View File
@@ -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 {
+1
View File
@@ -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:
@@ -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);
};
@@ -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);
};
+4 -9
View File
@@ -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'),
},
});
+3 -2
View File
@@ -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;
};
+7 -1
View File
@@ -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: {};
@@ -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;
@@ -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<HTMLElement>(null);
const toolbarWrapperRef = useRef<HTMLDivElement>(null);
const toolbarContentRef = useRef<HTMLDivElement>(null);
const toolbarControlsRef = useRef<HTMLDivElement>(null);
useIsomorphicLayoutEffect(() => {
const el = headerRef.current;
@@ -83,17 +88,35 @@ export const PluginHeader = (props: PluginHeaderProps) => {
};
}, []);
const titleContent = (
<>
<div className={classes.toolbarIcon}>{icon || <RiShapesLine />}</div>
<Text variant="body-medium">{title || 'Your plugin'}</Text>
</>
);
return (
<header ref={headerRef} className={classes.root}>
<PluginHeaderToolbar
icon={icon}
title={title}
titleLink={titleLink}
customActions={customActions}
hasTabs={hasTabs}
/>
<div className={classes.toolbar} data-has-tabs={hasTabs}>
<div className={classes.toolbarWrapper} ref={toolbarWrapperRef}>
<div className={classes.toolbarContent} ref={toolbarContentRef}>
<Text as="h1" variant="body-medium">
{titleLink ? (
<Link className={classes.toolbarName} href={titleLink}>
{titleContent}
</Link>
) : (
<div className={classes.toolbarName}>{titleContent}</div>
)}
</Text>
</div>
<div className={classes.toolbarControls} ref={toolbarControlsRef}>
{customActions}
</div>
</div>
</div>
{tabs && (
<Box bg="neutral" className={classes.tabsWrapper}>
<Box bg="neutral" className={classes.tabs}>
<Tabs onSelectionChange={onTabSelectionChange}>
<TabList>
{tabs?.map(tab => (
@@ -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<HTMLDivElement>(null);
const toolbarContentRef = useRef<HTMLDivElement>(null);
const toolbarControlsRef = useRef<HTMLDivElement>(null);
const titleContent = (
<>
<div className={classes.icon}>{icon || <RiShapesLine />}</div>
<Text variant="body-medium">{title || 'Your plugin'}</Text>
</>
);
return (
<Box bg="neutral" className={classes.root} data-has-tabs={hasTabs}>
<div className={classes.wrapper} ref={toolbarWrapperRef}>
<div className={classes.content} ref={toolbarContentRef}>
<Text as="h1" variant="body-medium">
{titleLink ? (
<Link className={classes.name} href={titleLink}>
{titleContent}
</Link>
) : (
<div className={classes.name}>{titleContent}</div>
)}
</Text>
</div>
<div className={classes.controls} ref={toolbarControlsRef}>
{customActions}
</div>
</div>
</Box>
);
};
@@ -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<PluginHeaderOwnProps>()({
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<PluginHeaderOwnProps>()({
className: {},
},
});
/**
* Component definition for PluginHeaderToolbar
* @internal
*/
export const PluginHeaderToolbarDefinition =
defineComponent<PluginHeaderToolbarOwnProps>()({
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: {},
},
});
@@ -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 {}
@@ -125,10 +125,10 @@ export const WithMockedURLTab3 = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL is mocked to be: <strong>/tab3</strong>
</Text>
<Text>
<Text as="p">
Notice how the "Tab 3 With long title" tab is selected (highlighted)
because it matches the current path.
</Text>
@@ -157,14 +157,14 @@ export const WithMockedURLNoMatch = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL is mocked to be: <strong>/some-other-page</strong>
</Text>
<Text>
<Text as="p">
No tab is selected because the current path doesn't match any tab's
href.
</Text>
<Text>
<Text as="p">
Tabs without href (like "Tab 1", "Tab 2", "Tab 3 With long title")
fall back to React Aria's internal state.
</Text>
@@ -195,14 +195,14 @@ export const ExactMatchingDefault = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/mentorship/events</strong>
</Text>
<Text>
<Text as="p">
Using default exact matching, only the "Events" tab is active because
it exactly matches the URL.
</Text>
<Text>
<Text as="p">
The "Mentorship" tab is NOT active even though the URL contains
"/mentorship".
</Text>
@@ -231,18 +231,18 @@ export const PrefixMatchingForNestedRoutes = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/mentorship/events</strong>
</Text>
<Text>
<Text as="p">
The "Mentorship" tab uses prefix matching and IS active because
"/mentorship/events" starts with "/mentorship".
</Text>
<Text>
<Text as="p">
The "Events" tab uses exact matching and is also active because it
exactly matches.
</Text>
<Text>
<Text as="p">
The "Catalog" tab uses prefix matching but is NOT active because the
URL doesn't start with "/catalog".
</Text>
@@ -313,22 +313,22 @@ export const MixedMatchingStrategies = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/dashboard/analytics/reports</strong>
</Text>
<Text>
<Text as="p">
"Overview" tab: exact matching, NOT active (doesn't exactly match
"/dashboard")
</Text>
<Text>
<Text as="p">
"Analytics" tab: prefix matching, IS active (URL starts with
"/dashboard/analytics")
</Text>
<Text>
<Text as="p">
"Settings" tab: prefix matching, NOT active (URL doesn't start with
"/dashboard/settings")
</Text>
<Text>
<Text as="p">
"Help" tab: exact matching, NOT active (doesn't exactly match
"/help")
</Text>
@@ -357,20 +357,20 @@ export const PrefixMatchingEdgeCases = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/foobar</strong>
</Text>
<Text>
<Text as="p">
"Foo" tab (prefix): NOT active - prevents "/foo" from matching
"/foobar"
</Text>
<Text>
<Text as="p">
"Foobar" tab (exact): IS active - exactly matches "/foobar"
</Text>
<Text>
<Text as="p">
"Foo (exact)" tab: NOT active - doesn't exactly match "/foobar"
</Text>
<Text>
<Text as="p">
This shows that prefix matching properly requires a "/" separator to
prevent false matches.
</Text>
@@ -399,20 +399,20 @@ export const PrefixMatchingWithSlash = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/foo/bar</strong>
</Text>
<Text>
<Text as="p">
"Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/"
</Text>
<Text>
<Text as="p">
"Foobar" tab (exact): NOT active - doesn't exactly match "/foobar"
</Text>
<Text>
<Text as="p">
"Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with
"/bar"
</Text>
<Text>
<Text as="p">
This demonstrates proper prefix matching with the "/" separator.
</Text>
</Box>
@@ -440,12 +440,14 @@ export const RootPathMatching = meta.story({
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
<Text as="p">
Current URL: <strong>/</strong>
</Text>
<Text> "Home" tab (exact): IS active - exactly matches "/"</Text>
<Text> "Home (prefix)" tab: IS active - "/" matches "/"</Text>
<Text>
<Text as="p">
"Home" tab (exact): IS active - exactly matches "/"
</Text>
<Text as="p"> "Home (prefix)" tab: IS active - "/" matches "/"</Text>
<Text as="p">
"Catalog" tab (prefix): NOT active - "/" doesn't start with
"/catalog"
</Text>
@@ -454,6 +456,51 @@ export const RootPathMatching = meta.story({
),
});
export const HrefWithQueryParams = meta.story({
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/cost-insights/dashboard?group=bar']}>
<Tabs>
<TabList>
<Tab
id="dashboard"
href="/cost-insights/dashboard?group=foo"
matchStrategy="prefix"
>
Dashboard
</Tab>
<Tab
id="alerts"
href="/cost-insights/alerts?group=foo"
matchStrategy="prefix"
>
Alerts
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text as="p">
Current URL: <strong>/cost-insights/dashboard?group=bar</strong>
</Text>
<Text as="p">
Tab hrefs include query params (e.g., ?group=foo) but the current URL
has different query params (?group=bar).
</Text>
<Text as="p">
"Dashboard" tab: IS active matching ignores query params and
compares only the pathname.
</Text>
<Text as="p">
"Alerts" tab: NOT active pathname /cost-insights/alerts doesn't
match /cost-insights/dashboard.
</Text>
</Box>
</MemoryRouter>
),
});
export const AutoSelectionOfTabs = meta.story({
args: {
children: '',
@@ -488,10 +535,12 @@ export const AutoSelectionOfTabs = meta.story({
</Tabs>
{/* With hrefs */}
<Text>
{' '}
<strong>Case 2: With hrefs</strong> By default no selection is shown
because the URL doesn't match any tab's href.{' '}
<Text as="p">
<strong>Case 2: With hrefs</strong>
</Text>
<Text as="p">
By default no selection is shown because the URL doesn't match any
tab's href.
</Text>
<Tabs>
<TabList>
+12 -4
View File
@@ -79,6 +79,12 @@ const TabSelectionContext = createContext<TabSelectionContextValue | null>(
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) {
+2 -2
View File
@@ -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%);
@@ -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",
@@ -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;
@@ -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(),
);
});
});
@@ -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',
},
},
],
}),
},
];
@@ -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(),
);
});
});
@@ -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<AccessLevel, AccessLevel.ADMIN>;
const accessLevelMapping: Record<string, number> = {
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);
}
},
});
};
@@ -15,6 +15,7 @@
*/
export * from './gitlab';
export * from './gitlabGroupEnsureExists';
export * from './gitlabGroupAccessAction';
export * from './gitlabIssueCreate';
export * from './gitlabIssueEdit';
export * from './gitlabMergeRequest';
@@ -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 }),
@@ -223,6 +223,7 @@ export const scaffolderPlugin = createBackendPlugin({
createScaffolderActions({
actionsRegistry: actionsRegistryService,
scaffolderService,
auth,
});
const router = await createRouter({
@@ -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: [],
},
];
}
@@ -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)),
},
};
},
});
};
@@ -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);
};
@@ -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,
};
}
@@ -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,
},
};
},
});
};
+1
View File
@@ -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"