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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/ui': patch
|
||||
---
|
||||
|
||||
Fixed neutral-1 hover & pressed state in light mode.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
+7
-11
@@ -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);
|
||||
};
|
||||
@@ -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'),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Vendored
+3
-2
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+164
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
+110
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user