Merge branch 'canon-props-restructure' into canon-flex
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-gitlab': minor
|
||||
---
|
||||
|
||||
Support empty repository creation in gitlab without workspace pushing and conditionally skip if the repository already exists.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/canon': patch
|
||||
---
|
||||
|
||||
Removed client directive as they are not needed in React 18.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-home': patch
|
||||
---
|
||||
|
||||
Enable collision prevention by default in custom home page.
|
||||
|
||||
This change ensures that items in the home page will not collide with each other
|
||||
making the user experience better.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-org': patch
|
||||
---
|
||||
|
||||
Added support for `spec.profile.displayName` to be used in the `MyGroupsSidebarItem` component via the `EntityDisplayName` component when you are a member of multiple Groups.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/canon': minor
|
||||
---
|
||||
|
||||
**BREAKING**: Fixing css structure and making sure that props are applying the correct styles for all responsive values.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/canon': patch
|
||||
---
|
||||
|
||||
Fixing css structure and making sure that props are applying the correct styles for all responsive values.
|
||||
@@ -127,6 +127,10 @@ jobs:
|
||||
- name: build all packages
|
||||
run: yarn backstage-cli repo build --all
|
||||
|
||||
# For now canon has a custom build script and needs to be built separately
|
||||
- name: build canon
|
||||
run: yarn --cwd packages/canon build
|
||||
|
||||
- name: verify type dependencies
|
||||
run: yarn lint:type-deps
|
||||
|
||||
|
||||
@@ -110,6 +110,10 @@ jobs:
|
||||
- name: build
|
||||
run: yarn backstage-cli repo build --all
|
||||
|
||||
# For now canon has a custom build script and needs to be built separately
|
||||
- name: build canon
|
||||
run: yarn --cwd packages/canon build
|
||||
|
||||
- name: verify type dependencies
|
||||
run: yarn lint:type-deps
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
run: |
|
||||
mkdir -p ./pr
|
||||
echo $PR_NUMBER > ./pr/pr_number
|
||||
- uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
- uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: pr_number-${{ github.event.pull_request.number }}
|
||||
path: pr/
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: 'Upload artifact'
|
||||
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PropsTable } from '@/components/PropsTable';
|
||||
import { icons } from '@backstage/canon';
|
||||
import { IconPreview } from '@/snippets/icon';
|
||||
import { Snippet } from '@/components/Snippet';
|
||||
import { Tabs } from '@/components/Tabs';
|
||||
@@ -44,7 +43,7 @@ Icons are used to represent an action or a state.
|
||||
<PropsTable
|
||||
data={{
|
||||
name: {
|
||||
type: Object.keys(icons),
|
||||
type: 'icon',
|
||||
responsive: false,
|
||||
},
|
||||
size: {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { CodeBlockProps } from '.';
|
||||
import { Text } from '../../../../packages/canon';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export const CodeBlockClient = ({
|
||||
out,
|
||||
title,
|
||||
}: {
|
||||
out: string;
|
||||
title?: CodeBlockProps['title'];
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.codeBlock}>
|
||||
{title && (
|
||||
<div className={styles.title}>
|
||||
<Text variant="body">{title}</Text>
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: out }} className={styles.code} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { BundledLanguage } from 'shiki';
|
||||
import { codeToHtml } from 'shiki';
|
||||
import { Text } from '../../../../packages/canon/src/components/Text';
|
||||
import styles from './styles.module.css';
|
||||
import { CodeBlockClient } from './client';
|
||||
|
||||
interface CodeBlockProps {
|
||||
export interface CodeBlockProps {
|
||||
lang?: BundledLanguage;
|
||||
title?: string;
|
||||
code?: string;
|
||||
@@ -19,14 +17,5 @@ export async function CodeBlock({ lang = 'tsx', title, code }: CodeBlockProps) {
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.codeBlock}>
|
||||
{title && (
|
||||
<div className={styles.title}>
|
||||
<Text variant="body">{title}</Text>
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: out }} className={styles.code} />
|
||||
</div>
|
||||
);
|
||||
return <CodeBlockClient out={out} title={title} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Icon, Text } from '../../../../packages/canon';
|
||||
import styles from './styles.module.css';
|
||||
import { Text } from '../../../../packages/canon/src/components/Text';
|
||||
import { Icon } from '../../../../packages/canon/src/components/Icon';
|
||||
|
||||
interface BaseUIProps {
|
||||
href: string;
|
||||
|
||||
@@ -13,12 +13,10 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Icon } from '../../../../packages/canon/src/components/Icon';
|
||||
import type { IconNames } from '../../../../packages/canon/src/components/Icon';
|
||||
import { Text } from '../../../../packages/canon/src/components/Text';
|
||||
import { icons } from '../../../../packages/canon/src/components/Icon/icons';
|
||||
import { Text, Icon, icons } from '../../../../packages/canon';
|
||||
import type { IconNames } from '../../../../packages/canon';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export const IconLibrary = () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
import * as Table from '../Table';
|
||||
import { Chip } from '../Chip';
|
||||
import { icons } from '../../../../packages/canon';
|
||||
|
||||
// Define a more specific type for the data object
|
||||
type PropData = {
|
||||
@@ -33,7 +35,9 @@ export const PropsTable = <T extends Record<string, PropData>>({
|
||||
<div
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: '0.375rem' }}
|
||||
>
|
||||
{Array.isArray(data[n].type) ? (
|
||||
{data[n].type === 'icon' ? (
|
||||
Object.keys(icons).map(icon => <Chip key={icon}>{icon}</Chip>)
|
||||
) : Array.isArray(data[n].type) ? (
|
||||
data[n].type.map(t => <Chip key={t}>{t}</Chip>)
|
||||
) : (
|
||||
<Chip>{data[n].type}</Chip>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { CodeBlock } from '../CodeBlock';
|
||||
import { Text } from '../../../../packages/canon/src/components/Text';
|
||||
import { Collapsible } from '@base-ui-components/react/collapsible';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
@@ -31,7 +30,7 @@ export const Snippet = ({
|
||||
{preview}
|
||||
</div>
|
||||
<Collapsible.Trigger className={styles.trigger}>
|
||||
<Text variant="body">View code</Text>
|
||||
View code
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Panel className={styles.panel}>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
right: 16px;
|
||||
bottom: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .previewContent {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Tabs as TabsPrimitive } from '@base-ui-components/react/tabs';
|
||||
import { Text } from '../../../../packages/canon';
|
||||
import styles from './styles.module.css';
|
||||
import { Text } from '../../../../packages/canon/src/components/Text';
|
||||
|
||||
export const Root = ({
|
||||
className,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Box } from '../../packages/canon/src/components/Box';
|
||||
|
||||
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
return {
|
||||
// Allows customizing built-in components, e.g. to add styling.
|
||||
h1: ({ children }) => (
|
||||
<Box style={{ marginTop: '4rem' }}>
|
||||
<h1
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { Input, Field } from '../../../packages/canon';
|
||||
|
||||
export const FieldPreview = () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { Input, Grid } from '../../../packages/canon';
|
||||
|
||||
export const InputPreview = () => {
|
||||
|
||||
+3
-1
@@ -274,13 +274,15 @@ The last step is to add the provider to the `SignInPage` so users can sign in wi
|
||||
new provider, please follow the [Sign In Configuration][3] docs, here's where you import
|
||||
and use the API reference we defined earlier.
|
||||
|
||||
## Note
|
||||
:::note Note
|
||||
|
||||
These steps apply to most if not all the providers, including custom providers, the main
|
||||
difference between different providers will be the contents of the API factory, the code
|
||||
in the Auth Provider Factory, the resolver, and the different variables each provider
|
||||
needs in the YAML config or env variables.
|
||||
|
||||
:::
|
||||
|
||||
[1]: https://backstage.io/docs/auth/identity-resolver
|
||||
[2]: https://backstage.io/docs/auth/microsoft/provider#create-an-app-registration-on-azure
|
||||
[3]: https://backstage.io/docs/auth/#sign-in-configuration
|
||||
|
||||
@@ -214,7 +214,11 @@ if `prevCursor` exists, it can be used to retrieve the previous batch of entitie
|
||||
|
||||
Lists entities.
|
||||
|
||||
**NOTE**: This endpoint is deprecated in favor of `GET /entities/by-query`, which provides a more efficient implementation and cursor based pagination.
|
||||
:::note Note
|
||||
|
||||
This endpoint is deprecated in favor of `GET /entities/by-query`, which provides a more efficient implementation and cursor based pagination.
|
||||
|
||||
:::
|
||||
|
||||
The endpoint supports the following query parameters, described in sections
|
||||
below:
|
||||
|
||||
@@ -375,7 +375,9 @@ Fields of a link are:
|
||||
| `icon` | String | [Optional] A key representing a visual icon to be displayed in the UI. |
|
||||
| `type` | String | [Optional] An optional value to categorize links into specific groups. |
|
||||
|
||||
_NOTE_: The `icon` field value is meant to be a semantic key that will map to a
|
||||
:::note Note
|
||||
|
||||
The `icon` field value is meant to be a semantic key that will map to a
|
||||
specific icon that may be provided by an icon library (e.g. `material-ui`
|
||||
icons). These keys should be a sequence of `[a-z0-9A-Z]`, possibly separated by
|
||||
one of `[-_.]`. Backstage may support some basic icons out of the box such as those [defined in app-defaults](https://github.com/backstage/backstage/blob/master/packages/app-defaults/src/defaults/icons.tsx), but the
|
||||
@@ -383,6 +385,8 @@ Backstage integrator will ultimately be left to provide the appropriate icon
|
||||
component mappings. A generic fallback icon would be provided if a mapping
|
||||
cannot be resolved.
|
||||
|
||||
:::
|
||||
|
||||
The semantics of the `type` field are undefined. The adopter is free to define their own set of types and utilize them as they wish. Some potential use cases can be to utilize the type field to validate certain links exist on entities or to create customized UI components for specific link types.
|
||||
|
||||
## Common to All Kinds: Relations
|
||||
|
||||
@@ -263,14 +263,17 @@ Idempotent action could be achieved via the usage of checkpoints.
|
||||
Example:
|
||||
|
||||
```ts title="plugins/my-company-scaffolder-actions-plugin/src/vendor/my-custom-action.ts"
|
||||
const res = await ctx.checkpoint?.('create.projects', async () => {
|
||||
const projectStgId = createStagingProjectId();
|
||||
const projectProId = createProductionProjectId();
|
||||
const res = await ctx.checkpoint?.({
|
||||
key: 'create.projects',
|
||||
fn: async () => {
|
||||
const projectStgId = createStagingProjectId();
|
||||
const projectProId = createProductionProjectId();
|
||||
|
||||
return {
|
||||
projectStgId,
|
||||
projectProId,
|
||||
};
|
||||
return {
|
||||
projectStgId,
|
||||
projectProId,
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -150,6 +150,32 @@ const routes = (
|
||||
);
|
||||
```
|
||||
|
||||
### Async Validation Function
|
||||
|
||||
A validation function can be asyncronous and use [Utility APIs](https://backstage.io/docs/api/utility-apis/) via the `ApiHolder` in the [field validation context](https://backstage.io/docs/reference/plugin-scaffolder-react.customfieldvalidator). The example below uses the `catalogApiRef` to check if the submitted value (in this scenario an entity ref) exists in the catalog.
|
||||
|
||||
```tsx
|
||||
import { FieldValidation } from '@rjsf/utils';
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
|
||||
/*
|
||||
This validation function checks if the submitted entity ref value is present in the catalog.
|
||||
*/
|
||||
|
||||
export const customFieldExtensionValidator = async (
|
||||
value: string,
|
||||
validation: FieldValidation,
|
||||
context: { apiHolder: ApiHolder },
|
||||
) => {
|
||||
const catalogApi = context.apiHolder.get(catalogApiRef);
|
||||
|
||||
if ((await catalogApi?.getEntityByRef(value)) === undefined) {
|
||||
validation.addError('Entity not found');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Using the Custom Field Extension
|
||||
|
||||
Once it's been passed to the `ScaffolderPage` you should now be able to use the
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import { Icon } from '../Icon';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useCanon } from '../../contexts/canon';
|
||||
import type { IconProps } from './types';
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { ElementRef, forwardRef } from 'react';
|
||||
import { Input as InputPrimitive } from '@base-ui-components/react/input';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { createContext, ReactNode, useContext } from 'react';
|
||||
import { icons } from '../components/Icon/icons';
|
||||
import { IconMap, IconNames } from '../components/Icon/types';
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export function extractProps(
|
||||
props: {
|
||||
className?: string;
|
||||
@@ -27,9 +28,6 @@ export function extractProps(
|
||||
let style: React.CSSProperties = props.style || {};
|
||||
|
||||
for (const key in propDefs) {
|
||||
if (key === 'gap') {
|
||||
console.log(key, propDefs[key]);
|
||||
}
|
||||
// Check if the prop is present or has a default value
|
||||
if (
|
||||
!props.hasOwnProperty(key) &&
|
||||
@@ -46,10 +44,6 @@ export function extractProps(
|
||||
const propDefsClassName = propDefs[key].className;
|
||||
const isResponsive = propDefs[key].responsive;
|
||||
|
||||
if (key === 'gap') {
|
||||
console.log('coco', key, propDefs[key], value);
|
||||
}
|
||||
|
||||
const handleValue = (val: any, prefix: string = '') => {
|
||||
if (propDefsValues.includes(val)) {
|
||||
className += ` ${prefix}${propDefsClassName}-${val}`;
|
||||
|
||||
@@ -349,7 +349,7 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
|
||||
compactType={props.compactType}
|
||||
style={props.style}
|
||||
allowOverlap={props.allowOverlap}
|
||||
preventCollision={props.preventCollision}
|
||||
preventCollision={props.preventCollision ?? true}
|
||||
draggableCancel=".overlayGridItem,.widgetSettingsDialog,.disabled"
|
||||
containerPadding={props.containerPadding}
|
||||
margin={props.containerMargin}
|
||||
|
||||
@@ -24,8 +24,15 @@ import { MyGroupsSidebarItem } from './MyGroupsSidebarItem';
|
||||
import GroupIcon from '@material-ui/icons/People';
|
||||
import { identityApiRef } from '@backstage/core-plugin-api';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
catalogApiRef,
|
||||
entityPresentationApiRef,
|
||||
entityRouteRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
describe('MyGroupsSidebarItem Test', () => {
|
||||
describe('For guests or users with no groups', () => {
|
||||
@@ -40,6 +47,10 @@ describe('MyGroupsSidebarItem Test', () => {
|
||||
apis={[
|
||||
[identityApiRef, identityApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
[
|
||||
entityPresentationApiRef,
|
||||
DefaultEntityPresentationApi.create({ catalogApi }),
|
||||
],
|
||||
]}
|
||||
>
|
||||
<MyGroupsSidebarItem
|
||||
@@ -88,6 +99,10 @@ describe('MyGroupsSidebarItem Test', () => {
|
||||
apis={[
|
||||
[identityApiRef, identityApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
[
|
||||
entityPresentationApiRef,
|
||||
DefaultEntityPresentationApi.create({ catalogApi }),
|
||||
],
|
||||
]}
|
||||
>
|
||||
<MyGroupsSidebarItem
|
||||
@@ -110,82 +125,104 @@ describe('MyGroupsSidebarItem Test', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('For users that are members of multiple groups', () => {
|
||||
it('MyGroupsSidebarItem should display a sub-menu with all their groups and a link to each group', async () => {
|
||||
const identityApi = mockApis.identity({
|
||||
userEntityRef: 'user:default/nigel.manning',
|
||||
ownershipEntityRefs: ['user:default/nigel.manning'],
|
||||
});
|
||||
const catalogApi = catalogApiMock.mock({
|
||||
getEntities: async () => ({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-a',
|
||||
title: 'Team A',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
},
|
||||
async function renderSideBarWithUserGroups() {
|
||||
const identityApi = mockApis.identity({
|
||||
userEntityRef: 'user:default/nigel.manning',
|
||||
ownershipEntityRefs: ['user:default/nigel.manning'],
|
||||
});
|
||||
const catalogApi = catalogApiMock.mock({
|
||||
getEntities: async () => ({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-a',
|
||||
namespace: 'default',
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-b',
|
||||
title: 'Team B',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-c',
|
||||
title: 'Team C',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
] as Entity[],
|
||||
}),
|
||||
});
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[identityApiRef, identityApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
]}
|
||||
>
|
||||
<MyGroupsSidebarItem
|
||||
singularTitle="My Squad"
|
||||
pluralTitle="My Squads"
|
||||
icon={GroupIcon}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-b',
|
||||
title: 'Team B',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-c',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
children: [],
|
||||
profile: {
|
||||
displayName: 'Team C',
|
||||
},
|
||||
},
|
||||
},
|
||||
] as Entity[],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(rendered.getByLabelText('My Squads')).toBeInTheDocument();
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[identityApiRef, identityApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
[
|
||||
entityPresentationApiRef,
|
||||
DefaultEntityPresentationApi.create({ catalogApi }),
|
||||
],
|
||||
]}
|
||||
>
|
||||
<MyGroupsSidebarItem
|
||||
singularTitle="My Squad"
|
||||
pluralTitle="My Squads"
|
||||
icon={GroupIcon}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('For users that are members of multiple groups', () => {
|
||||
beforeEach(async () => {
|
||||
await renderSideBarWithUserGroups();
|
||||
});
|
||||
|
||||
it('MyGroupsSidebarItem should display a sub-menu with all their groups and a link to each group', async () => {
|
||||
expect(screen.getByLabelText('My Squads')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('MyGroupsSidebarItem should display the displayName for each group in the list instead of the metadata name using the entityPresentationApi', async () => {
|
||||
await userEvent.hover(screen.getByLabelText('My Squads'));
|
||||
expect(screen.getByText('team-a')).toBeInTheDocument();
|
||||
expect(screen.getByText('Team B')).toBeInTheDocument();
|
||||
expect(screen.getByText('Team C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('When an additional filter is not provided', () => {
|
||||
const entityPresentationApi = {
|
||||
forEntity: jest.fn(),
|
||||
};
|
||||
it('catalogApi.getEntities() should be called with the default filter', async () => {
|
||||
const identityApi = mockApis.identity({
|
||||
userEntityRef: 'user:default/guest',
|
||||
@@ -197,6 +234,7 @@ describe('MyGroupsSidebarItem Test', () => {
|
||||
apis={[
|
||||
[identityApiRef, identityApi],
|
||||
[catalogApiRef, mockCatalogApi],
|
||||
[entityPresentationApiRef, entityPresentationApi],
|
||||
]}
|
||||
>
|
||||
<MyGroupsSidebarItem
|
||||
@@ -224,6 +262,10 @@ describe('MyGroupsSidebarItem Test', () => {
|
||||
});
|
||||
|
||||
describe('When an additional filter is provided', () => {
|
||||
const entityPresentationApi = {
|
||||
forEntity: jest.fn(),
|
||||
};
|
||||
|
||||
it('catalogApi.getEntities() should be called with an additional filter item', async () => {
|
||||
const identityApi = mockApis.identity({
|
||||
userEntityRef: 'user:default/guest',
|
||||
@@ -235,6 +277,7 @@ describe('MyGroupsSidebarItem Test', () => {
|
||||
apis={[
|
||||
[identityApiRef, identityApi],
|
||||
[catalogApiRef, mockCatalogApi],
|
||||
[entityPresentationApiRef, entityPresentationApi],
|
||||
]}
|
||||
>
|
||||
<MyGroupsSidebarItem
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { getCompoundEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
|
||||
|
||||
/**
|
||||
* MyGroupsSidebarItem can be added to your sidebar providing quick access to groups the logged in user is a member of
|
||||
*
|
||||
@@ -54,6 +56,7 @@ export const MyGroupsSidebarItem = (props: {
|
||||
const identityApi = useApi(identityApiRef);
|
||||
const catalogApi: CatalogApi = useApi(catalogApiRef);
|
||||
const catalogEntityRoute = useRouteRef(entityRouteRef);
|
||||
const entityPresentationApi = useApi(entityPresentationApiRef);
|
||||
|
||||
const { value: groups } = useAsync(async () => {
|
||||
const profile = await identityApi.getBackstageIdentity();
|
||||
@@ -68,11 +71,9 @@ export const MyGroupsSidebarItem = (props: {
|
||||
],
|
||||
fields: ['metadata', 'kind'],
|
||||
});
|
||||
|
||||
return response.items;
|
||||
}, []);
|
||||
|
||||
// Not a member of any groups
|
||||
if (!groups?.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -93,10 +94,11 @@ export const MyGroupsSidebarItem = (props: {
|
||||
return (
|
||||
<SidebarItem icon={icon} text={pluralTitle}>
|
||||
<SidebarSubmenu title={pluralTitle}>
|
||||
{groups?.map(function groupsMap(group) {
|
||||
{groups?.map(group => {
|
||||
const entityDisplayName = entityPresentationApi.forEntity(group);
|
||||
return (
|
||||
<SidebarSubmenuItem
|
||||
title={group.metadata.title || group.metadata.name}
|
||||
title={entityDisplayName.snapshot.primaryTitle}
|
||||
subtitle={
|
||||
group.metadata.namespace !== DEFAULT_NAMESPACE
|
||||
? group.metadata.namespace
|
||||
|
||||
@@ -138,7 +138,8 @@ export function createPublishGitlabAction(options: {
|
||||
repoUrl: string;
|
||||
defaultBranch?: string | undefined;
|
||||
repoVisibility?: 'internal' | 'private' | 'public' | undefined;
|
||||
sourcePath?: string | undefined;
|
||||
sourcePath?: string | boolean | undefined;
|
||||
skipExisting?: boolean | undefined;
|
||||
token?: string | undefined;
|
||||
gitCommitMessage?: string | undefined;
|
||||
gitAuthorName?: string | undefined;
|
||||
|
||||
@@ -38,6 +38,9 @@ const mockGitlabClient = {
|
||||
Namespaces: {
|
||||
show: jest.fn(),
|
||||
},
|
||||
Groups: {
|
||||
allProjects: jest.fn(),
|
||||
},
|
||||
Projects: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
@@ -88,6 +91,7 @@ describe('publish:gitlab', () => {
|
||||
it('should call initRepoAndPush with the correct values', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
|
||||
@@ -202,4 +202,23 @@ export const examples: TemplateExample[] = [
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Initializes a GitLab repository with the default readme and no files from workspace only if this repository does not exist yet.',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
id: 'publish',
|
||||
action: 'publish:gitlab',
|
||||
name: 'Publish to GitLab',
|
||||
input: {
|
||||
repoUrl: 'gitlab.com?repo=project_name&owner=group_name',
|
||||
skipExisting: true,
|
||||
initialize_with_readme: true,
|
||||
sourcePath: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -36,6 +36,9 @@ const mockGitlabClient = {
|
||||
Namespaces: {
|
||||
show: jest.fn(),
|
||||
},
|
||||
Groups: {
|
||||
allProjects: jest.fn(),
|
||||
},
|
||||
Projects: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
@@ -185,6 +188,7 @@ describe('publish:gitlab', () => {
|
||||
it('should work when there is a token provided through ctx.input', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
@@ -210,6 +214,7 @@ describe('publish:gitlab', () => {
|
||||
it('should call the correct Gitlab APIs when the owner is an organization', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
@@ -230,6 +235,7 @@ describe('publish:gitlab', () => {
|
||||
it('should call the correct Gitlab APIs when the owner is not an organization', async () => {
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: null });
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
@@ -247,9 +253,60 @@ describe('publish:gitlab', () => {
|
||||
expect(mockGitlabClient.ProtectedBranches.protect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call the creation Gitlab APIs when the repository already exists', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([
|
||||
{
|
||||
path: 'repo',
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
},
|
||||
{
|
||||
path: 'repo-name',
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
},
|
||||
]);
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: { ...mockContext.input, skipExisting: true },
|
||||
});
|
||||
|
||||
expect(mockGitlabClient.Namespaces.show).toHaveBeenCalledWith('owner');
|
||||
expect(mockGitlabClient.Projects.create).not.toHaveBeenCalled();
|
||||
expect(mockGitlabClient.Branches.create).not.toHaveBeenCalled();
|
||||
expect(mockGitlabClient.ProtectedBranches.protect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call the creation Gitlab APIs when the repository does not yet exists', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([
|
||||
{
|
||||
path: 'repo-name',
|
||||
},
|
||||
]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
|
||||
await action.handler(mockContext);
|
||||
|
||||
expect(mockGitlabClient.Namespaces.show).toHaveBeenCalledWith('owner');
|
||||
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
|
||||
namespaceId: 1234,
|
||||
name: 'repo',
|
||||
visibility: 'private',
|
||||
ci_config_path: '.gitlab-ci.yml',
|
||||
});
|
||||
expect(mockGitlabClient.Branches.create).not.toHaveBeenCalled();
|
||||
expect(mockGitlabClient.ProtectedBranches.protect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call the correct Gitlab APIs when using project settings with override of visibility and topics', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
@@ -271,6 +328,7 @@ describe('publish:gitlab', () => {
|
||||
it('should call the correct Gitlab APIs for branches and protectd branches when branch settings provided', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
id: 123456,
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
@@ -311,6 +369,7 @@ describe('publish:gitlab', () => {
|
||||
it('should call the correct Gitlab APIs for variables when their configuration is provided', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
id: 123456,
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
@@ -343,6 +402,7 @@ describe('publish:gitlab', () => {
|
||||
it('should call initRepoAndPush with the correct values', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
@@ -360,9 +420,34 @@ describe('publish:gitlab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call initRepoAndPush when sourcePath is false', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: { ...mockContext.input, sourcePath: false },
|
||||
});
|
||||
|
||||
expect(initRepoAndPush).not.toHaveBeenCalledWith({
|
||||
dir: mockContext.workspacePath,
|
||||
defaultBranch: 'master',
|
||||
remoteUrl: 'http://mockurl.git',
|
||||
auth: { username: 'oauth2', password: 'tokenlols' },
|
||||
logger: mockContext.logger,
|
||||
commitMessage: 'initial commit',
|
||||
gitAuthorInfo: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call initRepoAndPush with the correct default branch', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
@@ -418,6 +503,7 @@ describe('publish:gitlab', () => {
|
||||
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
@@ -464,6 +550,7 @@ describe('publish:gitlab', () => {
|
||||
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
@@ -484,6 +571,7 @@ describe('publish:gitlab', () => {
|
||||
it('should call output with the remoteUrl and repoContentsUrl and projectId', async () => {
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
id: 1234,
|
||||
@@ -524,6 +612,7 @@ describe('publish:gitlab', () => {
|
||||
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
|
||||
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
id: 123456,
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
|
||||
@@ -43,7 +43,8 @@ export function createPublishGitlabAction(options: {
|
||||
defaultBranch?: string;
|
||||
/** @deprecated in favour of settings.visibility field */
|
||||
repoVisibility?: 'private' | 'internal' | 'public';
|
||||
sourcePath?: string;
|
||||
sourcePath?: string | boolean;
|
||||
skipExisting?: boolean;
|
||||
token?: string;
|
||||
gitCommitMessage?: string;
|
||||
gitAuthorName?: string;
|
||||
@@ -124,8 +125,14 @@ export function createPublishGitlabAction(options: {
|
||||
sourcePath: {
|
||||
title: 'Source Path',
|
||||
description:
|
||||
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.',
|
||||
type: 'string',
|
||||
'Path within the workspace that will be used as the repository root. If omitted or set to true, the entire workspace will be published as the repository. If set to false, the created repository will be empty.',
|
||||
type: ['string', 'boolean'],
|
||||
},
|
||||
skipExisting: {
|
||||
title: 'Skip if repository exists',
|
||||
description:
|
||||
'Do not publish the repository if it already exists. The default value is false.',
|
||||
type: ['boolean'],
|
||||
},
|
||||
token: {
|
||||
title: 'Authentication Token',
|
||||
@@ -321,6 +328,10 @@ export function createPublishGitlabAction(options: {
|
||||
title: 'The git commit hash of the initial commit',
|
||||
type: 'string',
|
||||
},
|
||||
created: {
|
||||
title: 'Whether the repository was created or not',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -337,6 +348,7 @@ export function createPublishGitlabAction(options: {
|
||||
settings = {},
|
||||
branches = [],
|
||||
projectVariables = [],
|
||||
skipExisting = false,
|
||||
} = ctx.input;
|
||||
const { owner, repo, host } = parseRepoUrl(repoUrl, integrations);
|
||||
|
||||
@@ -391,129 +403,166 @@ export function createPublishGitlabAction(options: {
|
||||
targetNamespaceId = userId;
|
||||
}
|
||||
|
||||
const { id: projectId, http_url_to_repo } = await client.Projects.create({
|
||||
namespaceId: targetNamespaceId,
|
||||
name: repo,
|
||||
visibility: repoVisibility,
|
||||
...(topics.length ? { topics } : {}),
|
||||
...(Object.keys(settings).length ? { ...settings } : {}),
|
||||
const existingProjects = await client.Groups.allProjects(owner, {
|
||||
search: repo,
|
||||
});
|
||||
const existingProject = existingProjects.find(
|
||||
searchPathElem => searchPathElem.path === repo,
|
||||
);
|
||||
|
||||
// When setUserAsOwner is true the input token is expected to come from an unprivileged user GitLab
|
||||
// OAuth flow. In this case GitLab works in a way that allows the unprivileged user to
|
||||
// create the repository, but not to push the default protected branch (e.g. master).
|
||||
// In order to set the user as owner of the newly created repository we need to check that the
|
||||
// GitLab integration configuration for the matching host contains a token and use
|
||||
// such token to bootstrap a new privileged client.
|
||||
if (setUserAsOwner && integrationConfig.config.token) {
|
||||
const adminClient = new Gitlab({
|
||||
host: integrationConfig.config.baseUrl,
|
||||
token: integrationConfig.config.token,
|
||||
});
|
||||
|
||||
await adminClient.ProjectMembers.add(projectId, userId, 50);
|
||||
}
|
||||
|
||||
const remoteUrl = (http_url_to_repo as string).replace(/\.git$/, '');
|
||||
const repoContentsUrl = `${remoteUrl}/-/blob/${defaultBranch}`;
|
||||
|
||||
const gitAuthorInfo = {
|
||||
name: gitAuthorName
|
||||
? gitAuthorName
|
||||
: config.getOptionalString('scaffolder.defaultAuthor.name'),
|
||||
email: gitAuthorEmail
|
||||
? gitAuthorEmail
|
||||
: config.getOptionalString('scaffolder.defaultAuthor.email'),
|
||||
};
|
||||
const commitResult = await initRepoAndPush({
|
||||
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
|
||||
remoteUrl: http_url_to_repo as string,
|
||||
defaultBranch,
|
||||
auth: {
|
||||
username: 'oauth2',
|
||||
password: token,
|
||||
},
|
||||
logger: ctx.logger,
|
||||
commitMessage: gitCommitMessage
|
||||
? gitCommitMessage
|
||||
: config.getOptionalString('scaffolder.defaultCommitMessage'),
|
||||
gitAuthorInfo,
|
||||
});
|
||||
|
||||
if (branches) {
|
||||
for (const branch of branches) {
|
||||
const {
|
||||
name,
|
||||
protect = false,
|
||||
create = false,
|
||||
ref = 'master',
|
||||
} = branch;
|
||||
|
||||
if (create) {
|
||||
try {
|
||||
await client.Branches.create(projectId, name, ref);
|
||||
} catch (e) {
|
||||
throw new InputError(
|
||||
`Branch creation failed for ${name}. ${printGitlabError(e)}`,
|
||||
);
|
||||
}
|
||||
ctx.logger.info(
|
||||
`Branch ${name} created for ${projectId} with ref ${ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (protect) {
|
||||
try {
|
||||
await client.ProtectedBranches.protect(projectId, name);
|
||||
} catch (e) {
|
||||
throw new InputError(
|
||||
`Branch protection failed for ${name}. ${printGitlabError(e)}`,
|
||||
);
|
||||
}
|
||||
ctx.logger.info(`Branch ${name} protected for ${projectId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (projectVariables) {
|
||||
for (const variable of projectVariables) {
|
||||
const variableWithDefaults = Object.assign(variable, {
|
||||
variable_type: (variable.variable_type ??
|
||||
'env_var') as VariableType,
|
||||
protected: variable.protected ?? false,
|
||||
masked: variable.masked ?? false,
|
||||
raw: variable.raw ?? false,
|
||||
environment_scope: variable.environment_scope ?? '*',
|
||||
if (!skipExisting || (skipExisting && !existingProject)) {
|
||||
ctx.logger.info(`Creating repo ${repo} in namespace ${owner}.`);
|
||||
const { id: projectId, http_url_to_repo } =
|
||||
await client.Projects.create({
|
||||
namespaceId: targetNamespaceId,
|
||||
name: repo,
|
||||
visibility: repoVisibility,
|
||||
...(topics.length ? { topics } : {}),
|
||||
...(Object.keys(settings).length ? { ...settings } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
await client.ProjectVariables.create(
|
||||
projectId,
|
||||
variableWithDefaults.key,
|
||||
variableWithDefaults.value,
|
||||
{
|
||||
variableType: variableWithDefaults.variable_type,
|
||||
protected: variableWithDefaults.protected,
|
||||
masked: variableWithDefaults.masked,
|
||||
environmentScope: variableWithDefaults.environment_scope,
|
||||
description: variableWithDefaults.description,
|
||||
raw: variableWithDefaults.raw,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
throw new InputError(
|
||||
`Environment variable creation failed for ${
|
||||
variableWithDefaults.key
|
||||
}. ${printGitlabError(e)}`,
|
||||
);
|
||||
// When setUserAsOwner is true the input token is expected to come from an unprivileged user GitLab
|
||||
// OAuth flow. In this case GitLab works in a way that allows the unprivileged user to
|
||||
// create the repository, but not to push the default protected branch (e.g. master).
|
||||
// In order to set the user as owner of the newly created repository we need to check that the
|
||||
// GitLab integration configuration for the matching host contains a token and use
|
||||
// such token to bootstrap a new privileged client.
|
||||
if (setUserAsOwner && integrationConfig.config.token) {
|
||||
const adminClient = new Gitlab({
|
||||
host: integrationConfig.config.baseUrl,
|
||||
token: integrationConfig.config.token,
|
||||
});
|
||||
|
||||
await adminClient.ProjectMembers.add(projectId, userId, 50);
|
||||
}
|
||||
|
||||
const remoteUrl = (http_url_to_repo as string).replace(/\.git$/, '');
|
||||
const repoContentsUrl = `${remoteUrl}/-/blob/${defaultBranch}`;
|
||||
|
||||
const gitAuthorInfo = {
|
||||
name: gitAuthorName
|
||||
? gitAuthorName
|
||||
: config.getOptionalString('scaffolder.defaultAuthor.name'),
|
||||
email: gitAuthorEmail
|
||||
? gitAuthorEmail
|
||||
: config.getOptionalString('scaffolder.defaultAuthor.email'),
|
||||
};
|
||||
const shouldSkipPublish =
|
||||
typeof ctx.input.sourcePath === 'boolean' && !ctx.input.sourcePath;
|
||||
if (!shouldSkipPublish) {
|
||||
const commitResult = await initRepoAndPush({
|
||||
dir:
|
||||
typeof ctx.input.sourcePath === 'boolean'
|
||||
? ctx.workspacePath
|
||||
: getRepoSourceDirectory(
|
||||
ctx.workspacePath,
|
||||
ctx.input.sourcePath,
|
||||
),
|
||||
remoteUrl: http_url_to_repo as string,
|
||||
defaultBranch,
|
||||
auth: {
|
||||
username: 'oauth2',
|
||||
password: token,
|
||||
},
|
||||
logger: ctx.logger,
|
||||
commitMessage: gitCommitMessage
|
||||
? gitCommitMessage
|
||||
: config.getOptionalString('scaffolder.defaultCommitMessage'),
|
||||
gitAuthorInfo,
|
||||
});
|
||||
|
||||
if (branches) {
|
||||
for (const branch of branches) {
|
||||
const {
|
||||
name,
|
||||
protect = false,
|
||||
create = false,
|
||||
ref = 'master',
|
||||
} = branch;
|
||||
|
||||
if (create) {
|
||||
try {
|
||||
await client.Branches.create(projectId, name, ref);
|
||||
} catch (e) {
|
||||
throw new InputError(
|
||||
`Branch creation failed for ${name}. ${printGitlabError(
|
||||
e,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
ctx.logger.info(
|
||||
`Branch ${name} created for ${projectId} with ref ${ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (protect) {
|
||||
try {
|
||||
await client.ProtectedBranches.protect(projectId, name);
|
||||
} catch (e) {
|
||||
throw new InputError(
|
||||
`Branch protection failed for ${name}. ${printGitlabError(
|
||||
e,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
ctx.logger.info(`Branch ${name} protected for ${projectId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.output('commitHash', commitResult?.commitHash);
|
||||
}
|
||||
|
||||
if (projectVariables) {
|
||||
for (const variable of projectVariables) {
|
||||
const variableWithDefaults = Object.assign(variable, {
|
||||
variable_type: (variable.variable_type ??
|
||||
'env_var') as VariableType,
|
||||
protected: variable.protected ?? false,
|
||||
masked: variable.masked ?? false,
|
||||
raw: variable.raw ?? false,
|
||||
environment_scope: variable.environment_scope ?? '*',
|
||||
});
|
||||
|
||||
try {
|
||||
await client.ProjectVariables.create(
|
||||
projectId,
|
||||
variableWithDefaults.key,
|
||||
variableWithDefaults.value,
|
||||
{
|
||||
variableType: variableWithDefaults.variable_type,
|
||||
protected: variableWithDefaults.protected,
|
||||
masked: variableWithDefaults.masked,
|
||||
environmentScope: variableWithDefaults.environment_scope,
|
||||
description: variableWithDefaults.description,
|
||||
raw: variableWithDefaults.raw,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
throw new InputError(
|
||||
`Environment variable creation failed for ${
|
||||
variableWithDefaults.key
|
||||
}. ${printGitlabError(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.output('remoteUrl', remoteUrl);
|
||||
ctx.output('repoContentsUrl', repoContentsUrl);
|
||||
ctx.output('projectId', projectId);
|
||||
ctx.output('created', true);
|
||||
} else if (existingProject) {
|
||||
ctx.logger.info(`Repo ${repo} already exists in namespace ${owner}.`);
|
||||
const {
|
||||
id: projectId,
|
||||
http_url_to_repo,
|
||||
default_branch,
|
||||
} = existingProject;
|
||||
const remoteUrl = (http_url_to_repo as string).replace(/\.git$/, '');
|
||||
ctx.output('remoteUrl', remoteUrl);
|
||||
ctx.output('repoContentsUrl', `${remoteUrl}/-/blob/${default_branch}`);
|
||||
ctx.output('projectId', projectId);
|
||||
ctx.output('created', false);
|
||||
}
|
||||
|
||||
ctx.output('commitHash', commitResult?.commitHash);
|
||||
ctx.output('remoteUrl', remoteUrl);
|
||||
ctx.output('repoContentsUrl', repoContentsUrl);
|
||||
ctx.output('projectId', projectId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user