Provisioning: Remove domain restrictions for Git providers (#117850)

* Provisioning: Remove domain restrictions for GitHub, GitLab, and Bitbucket URLs

Allow self-hosted instances (GitHub Enterprise, self-managed GitLab, self-hosted
Bitbucket) to use provider-specific connectors instead of requiring the generic
git connector. Consolidate URL validation into shared config, apply generic
domain-stripping to all providers, and remove dead code.

* Provisioning: Move httpUtils to api/clients/provisioning/utils

Move the provisioning error helper to its new home alongside the other
provisioning API client utilities.

* Apply suggestion from @hugohaggmark

Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com>

* Stricter i18n sorting

---------

Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com>
Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>
This commit is contained in:
Alex Khomenko 2026-02-11 12:04:41 +02:00 committed by GitHub
parent 7de95500cc
commit 16c325d525
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 86 additions and 233 deletions

View file

@ -1,5 +1,11 @@
import { defineConfig } from 'i18next-cli';
const collator = new Intl.Collator('en-US', {
sensitivity: 'variant',
ignorePunctuation: false,
numeric: false,
});
export default defineConfig({
locales: ['en-US'], // Only en-US is updated - Crowdin will PR with other languages
extract: {
@ -14,7 +20,6 @@ export default defineConfig({
defaultNS: 'grafana',
functions: ['t', '*.t'],
transComponents: ['Trans'],
// eslint-disable-next-line no-restricted-syntax
sort: (a, b) => a.key.localeCompare(b.key, 'en-US'),
sort: (a, b) => collator.compare(a.key, b.key),
},
});

View file

@ -0,0 +1,34 @@
// Provisioning-specific error message helpers for HTTP and fetch errors.
import { t } from '@grafana/i18n';
import { isFetchError } from '@grafana/runtime';
import { isHttpError } from 'app/features/provisioning/guards';
export function getErrorMessage(err: unknown) {
if (isFetchError(err)) {
return err.data.message;
}
let errorMessage = t('provisioning.http-utils.request-failed', 'Request failed');
if (!isHttpError(err)) {
return errorMessage;
}
if (err.status === 401) {
return t('provisioning.http-utils.authentication-failed', 'Authentication failed. Please check your access token.');
}
if (err.status === 404) {
return t('provisioning.http-utils.resource-not-found', 'Resource not found. Please check the URL or repository.');
}
if (err.status === 403) {
return t('provisioning.http-utils.access-denied', 'Access denied. Please check your token permissions.');
}
if (err.message) {
return err.message;
}
return errorMessage;
}

View file

@ -1,9 +1,8 @@
import { t } from '@grafana/i18n';
import { Alert } from '@grafana/ui';
import { getErrorMessage } from 'app/api/clients/provisioning/utils/httpUtils';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { getErrorMessage } from '../utils/httpUtils';
import { ConnectionList } from './ConnectionList';
interface Props {

View file

@ -2,10 +2,10 @@ import { useEffect } from 'react';
import { Trans, t } from '@grafana/i18n';
import { Spinner, Stack, Text } from '@grafana/ui';
import { getErrorMessage } from 'app/api/clients/provisioning/utils/httpUtils';
import { Job, useListJobQuery } from 'app/api/clients/provisioning/v0alpha1';
import { StepStatusInfo } from '../Wizard/types';
import { getErrorMessage } from '../utils/httpUtils';
import { FinishedJobStatus } from './FinishedJobStatus';
import { JobContent } from './JobContent';

View file

@ -3,12 +3,12 @@ import { useMemo, useRef } from 'react';
import { intervalToAbbreviatedDurationString, TraceKeyValuePair } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { Badge, Box, Card, InteractiveTable, Spinner, Stack, Text } from '@grafana/ui';
import { getErrorMessage } from 'app/api/clients/provisioning/utils/httpUtils';
import { Job, Repository } from 'app/api/clients/provisioning/v0alpha1';
import KeyValuesTable from 'app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable';
import { ProvisioningAlert } from '../Shared/ProvisioningAlert';
import { useRepositoryAllJobs } from '../hooks/useRepositoryAllJobs';
import { getErrorMessage } from '../utils/httpUtils';
import { getStatusColor } from '../utils/repositoryStatus';
import { formatTimestamp } from '../utils/time';

View file

@ -5,13 +5,13 @@ import { useParams } from 'react-router-dom-v5-compat';
import { SelectableValue, urlUtil } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, EmptyState, Spinner, Stack, Tab, TabContent, TabsBar, Text, TextLink } from '@grafana/ui';
import { getErrorMessage } from 'app/api/clients/provisioning/utils/httpUtils';
import { useListRepositoryQuery } from 'app/api/clients/provisioning/v0alpha1';
import { Page } from 'app/core/components/Page/Page';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { isNotFoundError } from 'app/features/alerting/unified/api/util';
import { PROVISIONING_URL } from '../constants';
import { getErrorMessage } from '../utils/httpUtils';
import { RepositoryActions } from './RepositoryActions';
import { RepositoryOverview } from './RepositoryOverview';

View file

@ -37,6 +37,12 @@ const getProviderConfigs = (): Record<RepoType, Record<string, FieldConfig>> =>
},
url: {
label: t('provisioning.shared.url-label', 'Repository URL'),
validation: {
pattern: {
value: /^https:\/\/[^\/]+\/[^\/]+\/[^\/]+\/?$/,
message: t('provisioning.shared.url-pattern', 'Must be a valid repository URL (https://hostname/owner/repo)'),
},
},
},
tokenUser: {
label: t('provisioning.shared.token-user-label', 'Username'),
@ -67,11 +73,8 @@ const getProviderConfigs = (): Record<RepoType, Record<string, FieldConfig>> =>
placeholder: 'https://github.com/owner/repository',
required: true,
validation: {
...shared.url.validation,
required: t('provisioning.github.url-required', 'Repository URL is required'),
pattern: {
value: /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/?$/,
message: t('provisioning.github.url-pattern', 'Must be a valid GitHub repository URL'),
},
},
},
branch: {
@ -114,11 +117,8 @@ const getProviderConfigs = (): Record<RepoType, Record<string, FieldConfig>> =>
placeholder: 'https://gitlab.com/owner/repository',
required: true,
validation: {
...shared.url.validation,
required: t('provisioning.gitlab.url-required', 'Repository URL is required'),
pattern: {
value: /^https:\/\/gitlab\.com\/[^\/]+\/[^\/]+\/?$/,
message: t('provisioning.gitlab.url-pattern', 'Must be a valid GitLab repository URL'),
},
},
},
branch: {
@ -172,11 +172,8 @@ const getProviderConfigs = (): Record<RepoType, Record<string, FieldConfig>> =>
placeholder: 'https://bitbucket.org/owner/repository',
required: true,
validation: {
...shared.url.validation,
required: t('provisioning.bitbucket.url-required', 'Repository URL is required'),
pattern: {
value: /^https:\/\/bitbucket\.org\/[^\/]+\/[^\/]+\/?$/,
message: t('provisioning.bitbucket.url-pattern', 'Must be a valid Bitbucket repository URL'),
},
},
},
branch: {

View file

@ -6,10 +6,10 @@ import { skipToken } from '@reduxjs/toolkit/query';
import { useGetRepositoryRefsQuery } from '@grafana/api-clients/rtkq/provisioning/v0alpha1';
import { t } from '@grafana/i18n';
import { getErrorMessage } from 'app/api/clients/provisioning/utils/httpUtils';
import { useRepositoryStatus } from '../Wizard/hooks/useRepositoryStatus';
import { RepoType } from '../Wizard/types';
import { getErrorMessage } from '../utils/httpUtils';
import { isGitProvider } from '../utils/repositoryTypes';
export interface UseGetRepositoryRefsProps {

View file

@ -2,7 +2,7 @@ import { RepositorySpec } from 'app/api/clients/provisioning/v0alpha1';
import { RepositoryFormData } from '../types';
import { dataToSpec, specToData } from './data';
import { dataToSpec, generateRepositoryTitle, specToData } from './data';
const baseSync = {
enabled: true,
@ -55,3 +55,26 @@ describe('provisioning data mapping', () => {
expect(data.tokenUser).toBe('x-token-auth');
});
});
describe('generateRepositoryTitle', () => {
it.each([
{ type: 'github' as const, url: 'https://github.com/owner/repo', expected: 'owner/repo' },
{ type: 'gitlab' as const, url: 'https://gitlab.com/owner/repo', expected: 'owner/repo' },
{ type: 'bitbucket' as const, url: 'https://bitbucket.org/owner/repo', expected: 'owner/repo' },
{ type: 'git' as const, url: 'https://git.example.com/owner/repo.git', expected: 'owner/repo.git' },
])('strips SaaS domain from $type URL', ({ type, url, expected }) => {
expect(generateRepositoryTitle({ type, url })).toBe(expected);
});
it.each([
{ type: 'github' as const, url: 'https://github.enterprise.com/org/repo', expected: 'org/repo' },
{ type: 'gitlab' as const, url: 'https://gitlab.self-hosted.com/org/repo', expected: 'org/repo' },
{ type: 'bitbucket' as const, url: 'https://bitbucket.self-hosted.com/org/repo', expected: 'org/repo' },
])('strips self-hosted domain from $type URL', ({ type, url, expected }) => {
expect(generateRepositoryTitle({ type, url })).toBe(expected);
});
it('returns path for local type', () => {
expect(generateRepositoryTitle({ type: 'local', path: '/path/to/repo' })).toBe('/path/to/repo');
});
});

View file

@ -89,17 +89,12 @@ export const specToData = (spec: RepositorySpec): RepositoryFormData => {
export const generateRepositoryTitle = (repository: Pick<RepositoryFormData, 'type' | 'url' | 'path'>): string => {
switch (repository.type) {
case 'github':
const name = repository.url ?? 'github';
return name.replace('https://github.com/', '');
case 'gitlab':
const gitlabName = repository.url ?? 'gitlab';
return gitlabName.replace('https://gitlab.com/', '');
case 'bitbucket':
const bitbucketName = repository.url ?? 'bitbucket';
return bitbucketName.replace('https://bitbucket.org/', '');
case 'git':
const gitName = repository.url ?? 'git';
return gitName.replace(/^https?:\/\/[^\/]+\//, '');
case 'git': {
const repoUrl = repository.url ?? repository.type;
return repoUrl.replace(/^https?:\/\/[^\/]+\//, '');
}
case 'local':
return repository.path ?? 'local';
default:

View file

@ -1,196 +0,0 @@
import { t } from '@grafana/i18n';
import { isFetchError } from '@grafana/runtime';
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: `Basic ${btoa(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, GitLab, and Bitbucket 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 });
// Handle GitHub/GitLab format (direct array) and Bitbucket format ({ values: [...] })
const branches = Array.isArray(data) ? data : data?.values;
if (Array.isArray(branches) && branches.length > 0) {
allBranches.push(...branches);
hasMorePages = branches.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 }>> {
return fetchWithPagination(
(page) => `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/refs/branches?pagelen=100&page=${page}`,
headers
);
}
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;
}
} else if (isFetchError(err)) {
errorMessage = err.data.message;
}
return errorMessage;
}

View file

@ -12159,7 +12159,6 @@
"token-user-description": "The username that will be used to access the repository with the app password",
"token-user-required": "Username is required",
"url-description": "The Bitbucket repository URL",
"url-pattern": "Must be a valid Bitbucket repository URL",
"url-required": "Repository URL is required"
},
"bootstrap-step": {
@ -12430,7 +12429,6 @@
"token-label": "Personal Access Token",
"token-required": "GitHub token is required",
"url-description": "The GitHub repository URL",
"url-pattern": "Must be a valid GitHub repository URL",
"url-required": "Repository URL is required"
},
"gitlab": {
@ -12449,7 +12447,6 @@
"token-label": "Project Access Token",
"token-required": "GitLab token is required",
"url-description": "The GitLab repository URL",
"url-pattern": "Must be a valid GitLab repository URL",
"url-required": "Repository URL is required"
},
"history-view": {
@ -12472,10 +12469,8 @@
"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}}"
"resource-not-found": "Resource not found. Please check the URL or repository."
},
"instance-sync-deprecation": {
"message": "Instance sync is currently not fully supported and breaks library panels and alerts. To use library panels and alerts, disconnect your repository and reconnect it using folder sync instead.",
@ -12673,7 +12668,8 @@
"aria-label": "Progress Bar"
},
"token-user-label": "Username",
"url-label": "Repository URL"
"url-label": "Repository URL",
"url-pattern": "Must be a valid repository URL (https://hostname/owner/repo)"
},
"sidebar-item": {
"label-completed-step": "Completed step",