From 74cfe7b8035d52ee320b2518aa479b233853fd15 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Wed, 27 Aug 2025 18:47:37 +0300 Subject: [PATCH] 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 --- .../provisioning/Config/ConfigForm.tsx | 54 ++++- .../provisioning/Wizard/ConnectStep.tsx | 43 +++- .../Wizard/ProvisioningWizard.test.tsx | 25 ++- public/app/features/provisioning/guards.ts | 11 + .../provisioning/hooks/useBranchOptions.ts | 64 ++++++ .../features/provisioning/utils/httpUtils.ts | 194 ++++++++++++++++++ public/locales/en-US/grafana.json | 9 + 7 files changed, 380 insertions(+), 20 deletions(-) create mode 100644 public/app/features/provisioning/guards.ts create mode 100644 public/app/features/provisioning/hooks/useBranchOptions.ts create mode 100644 public/app/features/provisioning/utils/httpUtils.ts diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx index 6b5bf350383..71e35500de0 100644 --- a/public/app/features/provisioning/Config/ConfigForm.tsx +++ b/public/app/features/provisioning/Config/ConfigForm.tsx @@ -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({ 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} /> - - + + ( + onChange(option?.value || '')} + placeholder={gitFields.branchConfig.placeholder} + options={branchOptions} + loading={refsLoading} + isClearable + {...field} + /> + )} + /> diff --git a/public/app/features/provisioning/Wizard/ConnectStep.tsx b/public/app/features/provisioning/Wizard/ConnectStep.tsx index 49e15f56012..bd1e775a596 100644 --- a/public/app/features/provisioning/Wizard/ConnectStep.tsx +++ b/public/app/features/provisioning/Wizard/ConnectStep.tsx @@ -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(); 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} > @@ -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)} > - ( + onChange(option?.value || '')} + placeholder={gitFields.branchConfig.placeholder} + options={branchOptions} + loading={branchesLoading} + createCustomValue + isClearable + {...field} + /> + )} /> diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx index ac7f9dc6216..749e128d3df 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx @@ -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; 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 () => { diff --git a/public/app/features/provisioning/guards.ts b/public/app/features/provisioning/guards.ts new file mode 100644 index 00000000000..1ac4e9cfdae --- /dev/null +++ b/public/app/features/provisioning/guards.ts @@ -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; +} diff --git a/public/app/features/provisioning/hooks/useBranchOptions.ts b/public/app/features/provisioning/hooks/useBranchOptions.ts new file mode 100644 index 00000000000..fc0f6b7dfc9 --- /dev/null +++ b/public/app/features/provisioning/hooks/useBranchOptions.ts @@ -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> => { + 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, + }; +} diff --git a/public/app/features/provisioning/utils/httpUtils.ts b/public/app/features/provisioning/utils/httpUtils.ts new file mode 100644 index 00000000000..f433173892a --- /dev/null +++ b/public/app/features/provisioning/utils/httpUtils.ts @@ -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; +} + +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 { + 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 +): Promise> { + 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 +): Promise> { + 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 +): Promise> { + 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 +): Promise> { + 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> { + 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; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index cedc39df80e..92fb3f6423d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": {