Provisioning: Add branch selection in Onboarding Wizard and Config page (#110217)

* Provisioning: Add branch selection component with fetching logic

* Simplify api call

* Use fetch

* Simplify branch logic

* Remove branch selector

* Add repo fetching

* Restructure

* Add missing files

* Ad branch selection to config form

* Cleanup

* Remove isDfault

* useAsync

* useAsync for branch loading

* Remove repo fetching

* Refactor

* Allow custom value

* Add pagination

* dedupe

* useBranchOptions

* format

* Remove types

* comments

* Fix test

* Add translations
This commit is contained in:
Alex Khomenko
2025-08-27 18:47:37 +03:00
committed by GitHub
parent 7921c7da23
commit 74cfe7b803
7 changed files with 380 additions and 20 deletions
@@ -1,3 +1,4 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
@@ -6,6 +7,7 @@ import { t } from '@grafana/i18n';
import {
Button,
Checkbox,
Combobox,
ControlledCollapse,
Field,
Input,
@@ -14,7 +16,7 @@ import {
Stack,
Switch,
} from '@grafana/ui';
import { Repository } from 'app/api/clients/provisioning/v0alpha1';
import { Repository, useGetRepositoryRefsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo';
@@ -41,7 +43,8 @@ export interface ConfigFormProps {
data?: Repository;
}
export function ConfigForm({ data }: ConfigFormProps) {
const [submitData, request] = useCreateOrUpdateRepository(data?.metadata?.name);
const repositoryName = data?.metadata?.name;
const [submitData, request] = useCreateOrUpdateRepository(repositoryName);
const {
register,
handleSubmit,
@@ -54,7 +57,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
getValues,
} = useForm<RepositoryFormData>({ defaultValues: getDefaultValues(data?.spec) });
const isEdit = Boolean(data?.metadata?.name);
const isEdit = Boolean(repositoryName);
const [tokenConfigured, setTokenConfigured] = useState(isEdit);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
@@ -62,6 +65,23 @@ export function ConfigForm({ data }: ConfigFormProps) {
const targetOptions = useMemo(() => getTargetOptions(), []);
const isGitBased = isGitProvider(type);
const {
data: refsData,
isLoading: refsLoading,
error: refsError,
} = useGetRepositoryRefsQuery(!repositoryName || !isGitBased ? skipToken : { name: repositoryName });
const branchOptions = useMemo(() => {
if (!refsData?.items) {
return [];
}
return refsData.items.map((ref) => ({
label: ref.name,
value: ref.name,
}));
}, [refsData?.items]);
// Get field configurations based on provider type
const gitFields = isGitBased ? getGitProviderFields(type) : null;
const localFields = type === 'local' ? getLocalProviderFields(type) : null;
@@ -187,8 +207,32 @@ export function ConfigForm({ data }: ConfigFormProps) {
placeholder={gitFields.urlConfig.placeholder}
/>
</Field>
<Field noMargin label={gitFields.branchConfig.label} description={gitFields.branchConfig.description}>
<Input {...register('branch')} placeholder={gitFields.branchConfig.placeholder} />
<Field
noMargin
label={gitFields.branchConfig.label}
description={gitFields.branchConfig.description}
error={
errors?.branch?.message ||
(refsError ? t('provisioning.config-form.error-fetch-branches', 'Failed to fetch branches') : undefined)
}
invalid={Boolean(errors?.branch?.message || refsError)}
>
<Controller
name="branch"
control={control}
rules={gitFields.branchConfig.validation}
render={({ field: { ref, onChange, ...field } }) => (
<Combobox
invalid={Boolean(errors?.branch?.message || refsError)}
onChange={(option) => onChange(option?.value || '')}
placeholder={gitFields.branchConfig.placeholder}
options={branchOptions}
loading={refsLoading}
isClearable
{...field}
/>
)}
/>
</Field>
<Field noMargin label={gitFields.pathConfig.label} description={gitFields.pathConfig.description}>
<Input {...register('path')} />
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { Field, Input, SecretInput, Stack } from '@grafana/ui';
import { Combobox, Field, Input, SecretInput, Stack } from '@grafana/ui';
import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo';
import { useBranchOptions } from '../hooks/useBranchOptions';
import { getHasTokenInstructions } from '../utils/git';
import { isGitProvider } from '../utils/repositoryTypes';
@@ -17,14 +18,26 @@ export function ConnectStep() {
setValue,
formState: { errors },
getValues,
watch,
} = useFormContext<WizardFormData>();
const [tokenConfigured, setTokenConfigured] = useState(false);
// We don't need to dynamically react on repo type changes, so we use getValues for it
const type = getValues('repository.type');
const [repositoryUrl = '', repositoryToken = ''] = watch(['repository.url', 'repository.token']);
const isGitBased = isGitProvider(type);
// Get field configurations based on provider type
const {
options: branchOptions,
loading: branchesLoading,
error: branchesError,
} = useBranchOptions({
repositoryType: type,
repositoryUrl,
repositoryToken,
});
const gitFields = isGitBased ? getGitProviderFields(type) : null;
const localFields = !isGitBased ? getLocalProviderFields(type) : null;
const hasTokenInstructions = getHasTokenInstructions(type);
@@ -84,12 +97,12 @@ export function ConnectStep() {
label={gitFields.urlConfig.label}
description={gitFields.urlConfig.description}
error={errors?.repository?.url?.message}
invalid={!!errors?.repository?.url?.message}
invalid={Boolean(errors?.repository?.url?.message)}
required={gitFields.urlConfig.required}
>
<Input
{...register('repository.url', gitFields.urlConfig.validation)}
id="url"
id="repository-url"
placeholder={gitFields.urlConfig.placeholder}
/>
</Field>
@@ -99,13 +112,25 @@ export function ConnectStep() {
label={gitFields.branchConfig.label}
description={gitFields.branchConfig.description}
error={errors?.repository?.branch?.message}
invalid={!!errors?.repository?.branch?.message}
required={gitFields.branchConfig.required}
invalid={Boolean(errors?.repository?.branch?.message || branchesError)}
>
<Input
{...register('repository.branch', gitFields.branchConfig.validation)}
id="branch"
placeholder={gitFields.branchConfig.placeholder}
<Controller
name="repository.branch"
control={control}
rules={gitFields.branchConfig.validation}
render={({ field: { ref, onChange, ...field } }) => (
<Combobox
invalid={Boolean(errors?.repository?.branch?.message || branchesError)}
onChange={(option) => onChange(option?.value || '')}
placeholder={gitFields.branchConfig.placeholder}
options={branchOptions}
loading={branchesLoading}
createCustomValue
isClearable
{...field}
/>
)}
/>
</Field>
@@ -10,6 +10,7 @@ import {
useGetResourceStatsQuery,
} from 'app/api/clients/provisioning/v0alpha1';
import { useBranchOptions } from '../hooks/useBranchOptions';
import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
import { ProvisioningWizard } from './ProvisioningWizard';
@@ -23,6 +24,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
}));
jest.mock('../hooks/useCreateOrUpdateRepository');
jest.mock('../hooks/useBranchOptions');
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
...jest.requireActual('app/api/clients/provisioning/v0alpha1'),
useGetFrontendSettingsQuery: jest.fn(),
@@ -34,6 +36,7 @@ jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
const mockUseCreateOrUpdateRepository = useCreateOrUpdateRepository as jest.MockedFunction<
typeof useCreateOrUpdateRepository
>;
const mockUseBranchOptions = useBranchOptions as jest.MockedFunction<typeof useBranchOptions>;
const mockUseGetFrontendSettingsQuery = useGetFrontendSettingsQuery as jest.MockedFunction<
typeof useGetFrontendSettingsQuery
>;
@@ -87,7 +90,7 @@ async function fillConnectionForm(
}
if (type !== 'local' && data.branch) {
await user.type(screen.getByRole('textbox', { name: /Branch/i }), data.branch);
await user.type(screen.getByRole('combobox'), data.branch);
}
if (data.path) {
@@ -99,6 +102,16 @@ describe('ProvisioningWizard', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock useBranchOptions to prevent real API calls
mockUseBranchOptions.mockReturnValue({
options: [
{ label: 'main', value: 'main' },
{ label: 'develop', value: 'develop' },
],
loading: false,
error: null,
});
mockUseGetFrontendSettingsQuery.mockReturnValue({
data: {
items: [],
@@ -178,7 +191,7 @@ describe('ProvisioningWizard', () => {
expect(screen.getByRole('heading', { name: /1\. Connect to external storage/i })).toBeInTheDocument();
expect(screen.getByText('Personal Access Token *')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Repository URL/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Branch/i })).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Path/i })).toBeInTheDocument();
});
@@ -499,7 +512,7 @@ describe('ProvisioningWizard', () => {
expect(screen.getByText('Project Access Token *')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Repository URL/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Branch/i })).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Path/i })).toBeInTheDocument();
});
@@ -509,7 +522,7 @@ describe('ProvisioningWizard', () => {
expect(screen.getByText('App Password *')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Username/ })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Repository URL/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Branch/i })).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Path/i })).toBeInTheDocument();
});
@@ -519,7 +532,7 @@ describe('ProvisioningWizard', () => {
expect(screen.getByText('Access Token *')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Username/ })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Repository URL/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Branch/i })).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Path/i })).toBeInTheDocument();
});
@@ -531,7 +544,7 @@ describe('ProvisioningWizard', () => {
expect(screen.queryByPlaceholderText('glpat-xxxxxxxxxxxxxxxxxxxx')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('ATBBxxxxxxxxxxxxxxxx')).not.toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: /Repository URL/i })).not.toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: /Branch/i })).not.toBeInTheDocument();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
});
it('should accept tokenUser input for Bitbucket provider', async () => {
@@ -0,0 +1,11 @@
export interface HttpError extends Error {
status?: number;
}
export function isSupportedGitProvider(provider: string): provider is 'github' | 'gitlab' | 'bitbucket' {
return ['github', 'gitlab', 'bitbucket'].includes(provider);
}
export function isHttpError(err: unknown): err is HttpError {
return err instanceof Error && 'status' in err;
}
@@ -0,0 +1,64 @@
/**
* A hook to fetch all branches from a given repository.
* Used to populate the branch dropdown in the repository selection.
* We can't use the '/ref` endpoint at this point because the repository connection hasn't been created yet.
*/
import { useMemo } from 'react';
import { useAsync } from 'react-use';
import { RepoType } from '../Wizard/types';
import { isSupportedGitProvider } from '../guards';
import { fetchAllBranches, getErrorMessage, parseRepositoryUrl } from '../utils/httpUtils';
export interface UseBranchOptionsProps {
repositoryType: RepoType;
repositoryUrl: string;
repositoryToken: string;
}
export function useBranchOptions({ repositoryType, repositoryUrl = '', repositoryToken = '' }: UseBranchOptionsProps) {
const trimmedUrl = repositoryUrl.trim();
const trimmedToken = repositoryToken.trim();
const hasRequiredData = useMemo(() => {
if (!isSupportedGitProvider(repositoryType)) {
return false;
}
const hasUrl = trimmedUrl.length > 0;
const hasToken = trimmedToken.length > 0;
const repoInfo = hasUrl ? parseRepositoryUrl(trimmedUrl, repositoryType) : null;
return hasUrl && hasToken && repoInfo !== null;
}, [trimmedUrl, trimmedToken, repositoryType]);
const fetchOptions = useMemo(
() => async (): Promise<Array<{ label: string; value: string }>> => {
if (!hasRequiredData) {
return [];
}
const repoInfo = parseRepositoryUrl(trimmedUrl, repositoryType);
if (!repoInfo) {
throw new Error('Invalid repository URL format');
}
const branchData = await fetchAllBranches(repositoryType, repoInfo.owner, repoInfo.repo, trimmedToken);
return branchData.map((branch) => ({
label: branch.name,
value: branch.name,
}));
},
[hasRequiredData, trimmedUrl, trimmedToken, repositoryType]
);
const asyncState = useAsync(fetchOptions, [fetchOptions]);
return {
options: asyncState.value || [],
loading: asyncState.loading,
error: asyncState.error ? getErrorMessage(asyncState.error) : null,
};
}
@@ -0,0 +1,194 @@
import { t } from '@grafana/i18n';
import { HttpError, isHttpError } from '../guards';
export interface RepositoryInfo {
owner: string;
repo: string;
}
export interface ApiRequest {
url: string;
headers: Record<string, string>;
}
const githubUrlRegex = /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/?$/;
const gitlabUrlRegex = /^https:\/\/gitlab\.com\/([^\/]+)\/([^\/]+)\/?$/;
const bitbucketUrlRegex = /^https:\/\/bitbucket\.org\/([^\/]+)\/([^\/]+)\/?$/;
export function parseRepositoryUrl(url: string, type: string): RepositoryInfo | null {
let match: RegExpMatchArray | null = null;
switch (type) {
case 'github':
match = url.match(githubUrlRegex);
break;
case 'gitlab':
match = url.match(gitlabUrlRegex);
break;
case 'bitbucket':
match = url.match(bitbucketUrlRegex);
break;
default:
return null;
}
if (match && match[1] && match[2]) {
return {
owner: match[1],
repo: match[2].replace(/\.git$/, ''),
};
}
return null;
}
export function getProviderHeaders(repositoryType: string, token: string): Record<string, string> {
switch (repositoryType) {
case 'github':
return { Authorization: `Bearer ${token}` };
case 'gitlab':
return { 'Private-Token': token };
case 'bitbucket':
return { Authorization: `Bearer ${token}` };
default:
throw new Error(
t('provisioning.http-utils.unsupported-repository-type', 'Unsupported repository type: {{repositoryType}}', {
repositoryType,
})
);
}
}
export async function makeApiRequest(request: ApiRequest) {
const response = await window.fetch(request.url, {
method: 'GET',
headers: request.headers,
});
if (!response.ok) {
const errorData = await response.text();
console.error('API Error Response:', errorData);
const error: HttpError = new Error(
t('provisioning.http-utils.http-error', 'HTTP {{status}}: {{statusText}}', {
status: response.status,
statusText: response.statusText,
})
);
error.status = response.status;
throw error;
}
return response.json();
}
// GitHub and GitLab limit results to 100 items per page, so we need to paginate
async function fetchWithPagination(
buildUrl: (page: number) => string,
headers: Record<string, string>
): Promise<Array<{ name: string }>> {
const allBranches = [];
let page = 1;
let hasMorePages = true;
while (hasMorePages && page <= 10) {
const url = buildUrl(page);
const data = await makeApiRequest({ url, headers });
if (Array.isArray(data) && data.length > 0) {
allBranches.push(...data);
hasMorePages = data.length === 100;
page++;
} else {
hasMorePages = false;
}
}
return allBranches;
}
export async function fetchAllGitHubBranches(
owner: string,
repo: string,
headers: Record<string, string>
): Promise<Array<{ name: string }>> {
return fetchWithPagination(
(page) => `https://api.github.com/repos/${owner}/${repo}/branches?per_page=100&page=${page}`,
headers
);
}
export async function fetchAllGitLabBranches(
owner: string,
repo: string,
headers: Record<string, string>
): Promise<Array<{ name: string }>> {
const encodedPath = encodeURIComponent(`${owner}/${repo}`);
return fetchWithPagination(
(page) => `https://gitlab.com/api/v4/projects/${encodedPath}/repository/branches?per_page=100&page=${page}`,
headers
);
}
export async function fetchAllBitbucketBranches(
owner: string,
repo: string,
headers: Record<string, string>
): Promise<Array<{ name: string }>> {
const url = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/refs/branches?pagelen=1000`;
const data = await makeApiRequest({ url, headers });
if (data && Array.isArray(data.values)) {
return data.values;
}
return [];
}
export async function fetchAllBranches(
repositoryType: string,
owner: string,
repo: string,
token: string
): Promise<Array<{ name: string }>> {
const headers = getProviderHeaders(repositoryType, token);
switch (repositoryType) {
case 'github':
return fetchAllGitHubBranches(owner, repo, headers);
case 'gitlab':
return fetchAllGitLabBranches(owner, repo, headers);
case 'bitbucket':
return fetchAllBitbucketBranches(owner, repo, headers);
default:
throw new Error(
t('provisioning.http-utils.unsupported-repository-type', 'Unsupported repository type: {{repositoryType}}', {
repositoryType,
})
);
}
}
export function getErrorMessage(err: unknown) {
let errorMessage = t('provisioning.http-utils.request-failed', 'Request failed');
if (isHttpError(err)) {
if (err.status === 401) {
errorMessage = t(
'provisioning.http-utils.authentication-failed',
'Authentication failed. Please check your access token.'
);
} else if (err.status === 404) {
errorMessage = t(
'provisioning.http-utils.resource-not-found',
'Resource not found. Please check the URL or repository.'
);
} else if (err.status === 403) {
errorMessage = t('provisioning.http-utils.access-denied', 'Access denied. Please check your token permissions.');
} else if (err.message) {
errorMessage = err.message;
}
}
return errorMessage;
}
+9
View File
@@ -11232,6 +11232,7 @@
"description-enabled": "Once automatic pulling is enabled, the target cannot be changed.",
"description-read-only": "Resources can't be modified through Grafana.",
"description-title": "A human-readable name for the config",
"error-fetch-branches": "Failed to fetch branches",
"error-required": "This field is required.",
"error-save-repository": "Failed to save repository settings",
"label-automatic-pulling": "Automatic pulling",
@@ -11444,6 +11445,14 @@
"title-delete-all-configured-repositories": "Delete all configured repositories",
"title-legacy-storage-detected": "Legacy storage detected"
},
"http-utils": {
"access-denied": "Access denied. Please check your token permissions.",
"authentication-failed": "Authentication failed. Please check your access token.",
"http-error": "HTTP {{status}}: {{statusText}}",
"request-failed": "Request failed",
"resource-not-found": "Resource not found. Please check the URL or repository.",
"unsupported-repository-type": "Unsupported repository type: {{repositoryType}}"
},
"inline-secure-values-warning": "You need to save your access tokens again due to a system update",
"inline-token-warning-badge-text": "Token needs to be saved again",
"job-status": {