Provisioning: Extend Git-based workflows (#108537)

* Provisioning: Replace GitHub check with generic Git provider check

* Provisioning: Replace GitHub check with generic Git provider check

* Fix types

* Codeformat

* Imports
This commit is contained in:
Alex Khomenko
2025-07-24 05:15:17 +00:00
committed by GitHub
parent ada1c4dfdb
commit d4a609419a
18 changed files with 90 additions and 80 deletions
@@ -38,7 +38,6 @@ interface FormProps extends BulkDeleteProvisionResourceProps {
initialValues: BulkDeleteFormData;
repository: RepositoryView;
workflowOptions: Array<{ label: string; value: string }>;
isGitHub: boolean;
folderPath?: string;
}
@@ -59,15 +58,7 @@ type MoveResultSuccessState = {
repoUrl?: string;
};
function FormContent({
initialValues,
selectedItems,
repository,
workflowOptions,
folderPath,
isGitHub,
onDismiss,
}: FormProps) {
function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) {
// States
const [progress, setProgress] = useState<ProgressState | null>(null);
const [failureResults, setFailureResults] = useState<MoveResultFailed[] | undefined>();
@@ -233,7 +224,7 @@ function FormContent({
isNew={false}
workflow={workflow}
workflowOptions={workflowOptions}
isGitHub={isGitHub}
repository={repository}
hidePath
/>
@@ -263,7 +254,6 @@ export function BulkDeleteProvisionedResource({
const { repository, folder } = useGetResourceRepositoryView({ folderName: folderUid });
const workflowOptions = getWorkflowOptions(repository);
const isGitHub = repository?.type === 'github';
const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || '';
const timestamp = generateTimestamp();
@@ -284,7 +274,6 @@ export function BulkDeleteProvisionedResource({
initialValues={initialValues}
repository={repository}
workflowOptions={workflowOptions}
isGitHub={isGitHub}
folderPath={folderPath}
/>
);
@@ -111,7 +111,6 @@ const defaultHookData: ProvisionedFolderFormDataResult = {
{ label: 'Write directly', value: 'write' },
{ label: 'Create branch', value: 'branch' },
],
isGitHub: true,
repository: mockRepository,
folder: mockFolder,
initialValues: mockFormData,
@@ -23,7 +23,6 @@ interface FormProps extends DeleteProvisionedFolderFormProps {
repository?: RepositoryView;
workflowOptions: Array<{ label: string; value: string }>;
folder?: Folder;
isGitHub: boolean;
}
interface DeleteProvisionedFolderFormProps {
@@ -31,15 +30,7 @@ interface DeleteProvisionedFolderFormProps {
onDismiss?: () => void;
}
function FormContent({
initialValues,
parentFolder,
repository,
workflowOptions,
folder,
isGitHub,
onDismiss,
}: FormProps) {
function FormContent({ initialValues, parentFolder, repository, workflowOptions, folder, onDismiss }: FormProps) {
const resourceId = parentFolder?.uid || '';
const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation();
@@ -128,7 +119,7 @@ function FormContent({
isNew={false}
workflow={workflow}
workflowOptions={workflowOptions}
isGitHub={isGitHub}
repository={repository}
/>
{/* Delete / Cancel button */}
@@ -149,7 +140,7 @@ function FormContent({
}
export function DeleteProvisionedFolderForm({ parentFolder, onDismiss }: DeleteProvisionedFolderFormProps) {
const { workflowOptions, isGitHub, repository, folder, initialValues } = useProvisionedFolderFormData({
const { workflowOptions, repository, folder, initialValues } = useProvisionedFolderFormData({
folderUid: parentFolder?.uid,
action: 'delete',
title: parentFolder?.title,
@@ -167,7 +158,6 @@ export function DeleteProvisionedFolderForm({ parentFolder, onDismiss }: DeleteP
repository={repository}
workflowOptions={workflowOptions}
folder={folder}
isGitHub={isGitHub}
/>
);
}
@@ -146,7 +146,6 @@ const mockHookData: ProvisionedFolderFormDataResult = {
{ label: 'Commit directly', value: 'write' },
{ label: 'Create a branch', value: 'branch' },
],
isGitHub: true,
initialValues: {
title: '',
comment: '',
@@ -26,14 +26,13 @@ interface FormProps extends Props {
repository?: RepositoryView;
workflowOptions: Array<{ label: string; value: string }>;
folder?: Folder;
isGitHub: boolean;
}
interface Props {
parentFolder?: FolderDTO;
onDismiss?: () => void;
}
function FormContent({ initialValues, repository, workflowOptions, folder, isGitHub, onDismiss }: FormProps) {
function FormContent({ initialValues, repository, workflowOptions, folder, onDismiss }: FormProps) {
const { prURL } = usePullRequestParam();
const navigate = useNavigate();
const [create, request] = useCreateRepositoryFilesWithPathMutation();
@@ -159,7 +158,7 @@ function FormContent({ initialValues, repository, workflowOptions, folder, isGit
isNew={false}
workflow={workflow}
workflowOptions={workflowOptions}
isGitHub={isGitHub}
repository={repository}
hidePath
/>
@@ -197,7 +196,7 @@ function FormContent({ initialValues, repository, workflowOptions, folder, isGit
}
export function NewProvisionedFolderForm({ parentFolder, onDismiss }: Props) {
const { workflowOptions, isGitHub, repository, folder, initialValues } = useProvisionedFolderFormData({
const { workflowOptions, repository, folder, initialValues } = useProvisionedFolderFormData({
folderUid: parentFolder?.uid,
action: 'create',
title: '', // Empty title for new folders
@@ -215,7 +214,6 @@ export function NewProvisionedFolderForm({ parentFolder, onDismiss }: Props) {
repository={repository}
workflowOptions={workflowOptions}
folder={folder}
isGitHub={isGitHub}
/>
);
}
@@ -19,7 +19,6 @@ export interface ProvisionedFolderFormDataResult {
repository?: RepositoryView;
folder?: Folder;
workflowOptions: Array<{ label: string; value: string }>;
isGitHub: boolean;
initialValues?: BaseProvisionedFormData;
}
@@ -34,7 +33,6 @@ export function useProvisionedFolderFormData({
const { repository, folder, isLoading } = useGetResourceRepositoryView({ folderName: folderUid });
const workflowOptions = getWorkflowOptions(repository);
const isGitHub = repository?.type === 'github';
const timestamp = generateTimestamp();
const initialValues = useMemo(() => {
@@ -57,7 +55,6 @@ export function useProvisionedFolderFormData({
repository,
folder,
workflowOptions,
isGitHub,
initialValues,
};
}
@@ -3,14 +3,32 @@ import userEvent from '@testing-library/user-event';
import { ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { RepositoryView } from '../../../../api/clients/provisioning/v0alpha1';
import { ProvisionedDashboardFormData } from '../../saving/shared';
import { ResourceEditFormSharedFields } from './ResourceEditFormSharedFields';
const mockRepo: { github: RepositoryView; local: RepositoryView } = {
github: {
type: 'github',
name: 'test-repo',
title: 'Test Repo',
workflows: ['branch', 'write'],
target: 'folder',
},
local: {
type: 'local',
name: 'local-repo',
title: 'Local Repo',
workflows: [],
target: 'folder',
},
};
// Mock the i18n hook since it's used in the component
jest.mock('@grafana/i18n', () => ({
t: (_: string, defaultValue: string) => defaultValue,
Trans: ({ children }: { children: React.ReactNode }) => children,
Trans: ({ children }: { children: ReactNode }) => children,
}));
interface SetupOptions {
@@ -19,7 +37,7 @@ interface SetupOptions {
isNew?: boolean;
readOnly?: boolean;
workflow?: 'write' | 'branch';
isGitHub?: boolean;
repository?: RepositoryView;
}
function setup(options: SetupOptions = {}) {
@@ -32,7 +50,7 @@ function setup(options: SetupOptions = {}) {
isNew,
readOnly,
workflow,
isGitHub,
repository,
} = options;
const user = userEvent.setup();
@@ -58,7 +76,7 @@ function setup(options: SetupOptions = {}) {
isNew,
readOnly,
workflow,
isGitHub,
repository,
};
return {
@@ -80,14 +98,14 @@ describe('ResourceEditFormSharedFields', () => {
expect(screen.getByRole('textbox', { name: 'Comment' })).toBeInTheDocument();
});
it('should not render workflow fields when isGitHub is false', () => {
setup({ isGitHub: false });
it('should not render workflow fields when repository is false', () => {
setup({ repository: mockRepo.local });
expect(screen.queryByText('Workflow')).not.toBeInTheDocument();
});
it('should render workflow fields when isGitHub is true', () => {
setup({ isGitHub: true });
it('should render workflow fields when repository is true', () => {
setup({ repository: mockRepo.github });
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Write directly' })).toBeInTheDocument();
@@ -110,8 +128,8 @@ describe('ResourceEditFormSharedFields', () => {
expect(commentTextarea).toBeDisabled();
});
it('should not render workflow fields when readOnly is true and isGitHub is true', () => {
setup({ readOnly: true, isGitHub: true });
it('should not render workflow fields when readOnly is true and repository is true', () => {
setup({ readOnly: true, repository: mockRepo.github });
expect(screen.queryByText('Workflow')).not.toBeInTheDocument();
});
@@ -119,14 +137,14 @@ describe('ResourceEditFormSharedFields', () => {
describe('Workflow Fields', () => {
it('should not render branch field when workflow is write', () => {
setup({ formDefaultValues: { workflow: 'write' }, isGitHub: true, workflow: 'write' });
setup({ formDefaultValues: { workflow: 'write' }, repository: mockRepo.github, workflow: 'write' });
expect(screen.getByText('Workflow')).toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: /branch/i })).not.toBeInTheDocument();
});
it('should render branch field when workflow is branch', () => {
setup({ formDefaultValues: { workflow: 'branch' }, isGitHub: true, workflow: 'branch' });
setup({ formDefaultValues: { workflow: 'branch' }, repository: mockRepo.github, workflow: 'branch' });
expect(screen.getByText('Workflow')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /branch/i })).toBeInTheDocument();
@@ -154,7 +172,7 @@ describe('ResourceEditFormSharedFields', () => {
});
it('should allow selecting workflow options', async () => {
const { user } = setup({ isGitHub: true });
const { user } = setup({ repository: mockRepo.github });
const branchOption = screen.getByRole('radio', { name: 'Create branch' });
await user.click(branchOption);
@@ -163,7 +181,11 @@ describe('ResourceEditFormSharedFields', () => {
});
it('should allow typing in branch field when workflow is branch', async () => {
const { user } = setup({ formDefaultValues: { workflow: 'branch' }, isGitHub: true, workflow: 'branch' });
const { user } = setup({
formDefaultValues: { workflow: 'branch' },
repository: mockRepo.github,
workflow: 'branch',
});
const branchInput = screen.getByRole('textbox', { name: /branch/i });
await user.type(branchInput, 'feature-branch');
@@ -214,7 +236,11 @@ describe('ResourceEditFormSharedFields', () => {
describe('Validation', () => {
it('should show validation error for invalid branch name', async () => {
const { user } = setup({ formDefaultValues: { workflow: 'branch' }, isGitHub: true, workflow: 'branch' });
const { user } = setup({
formDefaultValues: { workflow: 'branch' },
repository: mockRepo.github,
workflow: 'branch',
});
const branchInput = screen.getByRole('textbox', { name: /branch/i });
await user.type(branchInput, 'invalid//branch'); // Invalid branch name with consecutive slashes
@@ -229,14 +255,14 @@ describe('ResourceEditFormSharedFields', () => {
describe('Edge Cases', () => {
it('should handle empty workflowOptions', () => {
setup({ workflowOptions: [], isGitHub: true });
setup({ workflowOptions: [], repository: mockRepo.github });
expect(screen.getByText('Workflow')).toBeInTheDocument();
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
});
it('should handle undefined props', () => {
setup({ readOnly: undefined, isGitHub: undefined });
setup({ readOnly: undefined, repository: mockRepo.local });
expect(screen.getByRole('textbox', { name: /Path/ })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Comment' })).toBeInTheDocument();
@@ -3,9 +3,11 @@ import { Controller, useFormContext } from 'react-hook-form';
import { t } from '@grafana/i18n';
import { Field, TextArea, Input, RadioButtonGroup } from '@grafana/ui';
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
import { BranchValidationError } from 'app/features/provisioning/Shared/BranchValidationError';
import { WorkflowOption } from 'app/features/provisioning/types';
import { validateBranchName } from 'app/features/provisioning/utils/git';
import { isGitProvider } from 'app/features/provisioning/utils/repositoryTypes';
interface DashboardEditFormSharedFieldsProps {
resourceType: 'dashboard' | 'folder';
@@ -13,12 +15,12 @@ interface DashboardEditFormSharedFieldsProps {
isNew?: boolean;
readOnly?: boolean;
workflow?: WorkflowOption;
isGitHub?: boolean;
repository?: RepositoryView;
hidePath?: boolean;
}
export const ResourceEditFormSharedFields = memo<DashboardEditFormSharedFieldsProps>(
({ readOnly = false, workflow, workflowOptions, isGitHub, isNew, resourceType, hidePath = false }) => {
({ readOnly = false, workflow, workflowOptions, repository, isNew, resourceType, hidePath = false }) => {
const {
control,
register,
@@ -64,7 +66,7 @@ export const ResourceEditFormSharedFields = memo<DashboardEditFormSharedFieldsPr
</Field>
{/* Workflow */}
{isGitHub && !readOnly && (
{repository?.type && isGitProvider(repository.type) && !readOnly && (
<>
<Field
noMargin
@@ -12,7 +12,7 @@ export interface SaveProvisionedDashboardProps {
}
export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: SaveProvisionedDashboardProps) {
const { isNew, defaultValues, loadedFromRef, isGitHub, workflowOptions, readOnly } =
const { isNew, defaultValues, loadedFromRef, workflowOptions, readOnly, repository } =
useProvisionedDashboardData(dashboard);
if (!defaultValues) {
@@ -27,7 +27,7 @@ export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: Save
isNew={isNew}
defaultValues={defaultValues}
loadedFromRef={loadedFromRef}
isGitHub={isGitHub}
repository={repository}
workflowOptions={workflowOptions}
readOnly={readOnly}
/>
@@ -121,7 +121,13 @@ function setup(props: Partial<Props> = {}) {
diffs: {},
},
isNew: true,
isGitHub: true,
repository: {
type: 'github',
name: 'test-repo',
title: 'Test Repo',
workflows: ['branch', 'write'],
target: 'folder',
},
defaultValues: {
ref: 'dashboard/2023-01-01-abcde',
path: 'test-dashboard.json',
@@ -7,6 +7,7 @@ import { Trans, t } from '@grafana/i18n';
import { getAppEvents, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { Alert, Button, Field, Input, Stack, TextArea } from '@grafana/ui';
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import kbn from 'app/core/utils/kbn';
import { Resource } from 'app/features/apiserver/types';
@@ -26,10 +27,10 @@ import { getProvisionedMeta } from './utils/getProvisionedMeta';
export interface Props extends SaveProvisionedDashboardProps {
isNew: boolean;
defaultValues: ProvisionedDashboardFormData;
isGitHub: boolean;
loadedFromRef?: string;
workflowOptions: Array<{ label: string; value: string }>;
readOnly: boolean;
repository?: RepositoryView;
}
export function SaveProvisionedDashboardForm({
@@ -39,9 +40,9 @@ export function SaveProvisionedDashboardForm({
changeInfo,
isNew,
loadedFromRef,
isGitHub,
workflowOptions,
readOnly,
repository,
}: Props) {
const navigate = useNavigate();
const appEvents = getAppEvents();
@@ -221,7 +222,7 @@ export function SaveProvisionedDashboardForm({
readOnly={readOnly}
workflow={workflow}
workflowOptions={workflowOptions}
isGitHub={isGitHub}
repository={repository}
isNew={isNew}
/>
@@ -59,7 +59,6 @@ export function useDefaultValues({ meta, defaultTitle, defaultDescription, loade
workflow: getDefaultWorkflow(repository, loadedFromRef),
},
isNew: !meta.k8s?.name,
isGitHub: repository?.type === 'github',
repository,
};
}
@@ -73,7 +72,6 @@ export interface ProvisionedDashboardData {
loadedFromRef?: string;
workflowOptions: Array<{ label: string; value: string }>;
isNew: boolean;
isGitHub: boolean;
readOnly: boolean;
}
@@ -105,12 +103,11 @@ export function useProvisionedDashboardData(dashboard: DashboardScene): Provisio
loadedFromRef,
workflowOptions: [],
isNew: false,
isGitHub: false,
readOnly: true,
};
}
const { values, isNew, isGitHub, repository } = defaultValuesResult;
const { values, isNew, repository } = defaultValuesResult;
const workflowOptions = getWorkflowOptions(repository, loadedFromRef);
const readOnly = !repository?.workflows?.length;
@@ -122,7 +119,6 @@ export function useProvisionedDashboardData(dashboard: DashboardScene): Provisio
loadedFromRef,
workflowOptions,
isNew,
isGitHub,
readOnly,
isLoading,
setIsLoading,
@@ -13,7 +13,7 @@ export interface Props {
* Drawer component for deleting a git provisioned dashboard.
*/
export function DeleteProvisionedDashboardDrawer({ dashboard, onDismiss }: Props) {
const { defaultValues, loadedFromRef, readOnly, isGitHub, workflowOptions, isNew } =
const { defaultValues, loadedFromRef, readOnly, workflowOptions, isNew, repository } =
useProvisionedDashboardData(dashboard);
if (!defaultValues) {
@@ -26,7 +26,7 @@ export function DeleteProvisionedDashboardDrawer({ dashboard, onDismiss }: Props
defaultValues={defaultValues}
loadedFromRef={loadedFromRef}
readOnly={readOnly}
isGitHub={isGitHub}
repository={repository}
isNew={isNew}
workflowOptions={workflowOptions}
onDismiss={onDismiss}
@@ -107,7 +107,6 @@ function setup(options: SetupOptions = {}) {
},
loadedFromRef: 'main',
readOnly: false,
isGitHub: true,
workflowOptions: [
{ label: 'Branch', value: 'branch' },
{ label: 'Write', value: 'write' },
@@ -5,7 +5,7 @@ import { AppEvents } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getAppEvents } from '@grafana/runtime';
import { Alert, Button, Drawer, Stack } from '@grafana/ui';
import { useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { RepositoryView, useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields';
@@ -17,10 +17,10 @@ export interface Props {
dashboard: DashboardScene;
defaultValues: ProvisionedDashboardFormData;
readOnly: boolean;
isGitHub: boolean;
isNew?: boolean;
workflowOptions: Array<{ label: string; value: string }>;
loadedFromRef?: string;
repository?: RepositoryView;
onDismiss: () => void;
}
@@ -33,9 +33,9 @@ export function DeleteProvisionedDashboardForm({
defaultValues,
loadedFromRef,
readOnly,
isGitHub,
isNew,
workflowOptions,
repository,
onDismiss,
}: Props) {
const methods = useForm<ProvisionedDashboardFormData>({ defaultValues });
@@ -127,7 +127,7 @@ export function DeleteProvisionedDashboardForm({
readOnly={readOnly}
workflow={workflow}
workflowOptions={workflowOptions}
isGitHub={isGitHub}
repository={repository}
/>
{/* Save / Cancel button */}
@@ -18,7 +18,7 @@ export function MoveProvisionedDashboardDrawer({
onDismiss,
onSuccess,
}: Props) {
const { defaultValues, loadedFromRef, readOnly, isGitHub, workflowOptions, isNew } =
const { defaultValues, loadedFromRef, readOnly, workflowOptions, isNew, repository } =
useProvisionedDashboardData(dashboard);
if (!defaultValues) {
@@ -31,7 +31,7 @@ export function MoveProvisionedDashboardDrawer({
defaultValues={defaultValues}
loadedFromRef={loadedFromRef}
readOnly={readOnly}
isGitHub={isGitHub}
repository={repository}
isNew={isNew}
workflowOptions={workflowOptions}
targetFolderUID={targetFolderUID}
@@ -70,7 +70,13 @@ function setup(props: Partial<Props> = {}) {
folder: { uid: '', title: '' },
},
readOnly: false,
isGitHub: true,
repository: {
type: 'github',
name: 'test-repo',
title: 'Test Repo',
workflows: ['branch', 'write'],
target: 'folder',
},
workflowOptions: [
{ label: 'Write', value: 'write' },
{ label: 'Branch', value: 'branch' },
@@ -8,6 +8,7 @@ import { getAppEvents } from '@grafana/runtime';
import { Alert, Button, Drawer, Field, Input, Spinner, Stack } from '@grafana/ui';
import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1';
import {
RepositoryView,
useCreateRepositoryFilesWithPathMutation,
useDeleteRepositoryFilesWithPathMutation,
useGetRepositoryFilesWithPathQuery,
@@ -23,12 +24,12 @@ export interface Props {
dashboard: DashboardScene;
defaultValues: ProvisionedDashboardFormData;
readOnly: boolean;
isGitHub: boolean;
isNew?: boolean;
workflowOptions: Array<{ label: string; value: string }>;
loadedFromRef?: string;
targetFolderUID?: string;
targetFolderTitle?: string;
repository?: RepositoryView;
onDismiss: () => void;
onSuccess: (folderUID: string, folderTitle: string) => void;
}
@@ -38,16 +39,17 @@ export function MoveProvisionedDashboardForm({
defaultValues,
loadedFromRef,
readOnly,
isGitHub,
isNew,
workflowOptions,
targetFolderUID,
targetFolderTitle,
repository,
onDismiss,
onSuccess,
}: Props) {
const methods = useForm<ProvisionedDashboardFormData>({ defaultValues });
const { editPanel: panelEditor } = dashboard.useState();
const { handleSubmit, watch } = methods;
const appEvents = getAppEvents();
@@ -213,7 +215,7 @@ export function MoveProvisionedDashboardForm({
readOnly={readOnly}
workflow={workflow}
workflowOptions={workflowOptions}
isGitHub={isGitHub}
repository={repository}
/>
<Stack gap={2}>