mirror of
https://github.com/grafana/grafana.git
synced 2026-02-18 18:20:52 -05:00
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:
parent
7de95500cc
commit
16c325d525
12 changed files with 86 additions and 233 deletions
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
34
public/app/api/clients/provisioning/utils/httpUtils.ts
Normal file
34
public/app/api/clients/provisioning/utils/httpUtils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue