Provisioning: Add Github Enterprise frontend (#127209)

* Use generic IsGithubBased check instead of == github

* Provisioning: GithubEnterprise support frontend

* refactor to use useConnectionHook

* move stuff

* tests

* address comments

* Add Enterprise to GHE icon

* comments, pass in url for FileHistoryPage

* comments
This commit is contained in:
Florie Cai 2026-06-30 13:31:46 -04:00 committed by GitHub
parent 24c4289638
commit 144871919b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 664 additions and 240 deletions

View file

@ -2306,11 +2306,6 @@
"count": 26
}
},
"public/app/features/provisioning/Shared/TokenPermissionsInfo.tsx": {
"react/no-unescaped-entities": {
"count": 2
}
},
"public/app/features/query/components/QueryEditorRow.tsx": {
"@grafana/no-gf-form": {
"count": 1

View file

@ -2,6 +2,7 @@ export const availableIconsIndex = {
google: true,
microsoft: true,
github: true,
'github-enterprise': true,
gitlab: true,
okta: true,
scim: true,

View file

@ -205,5 +205,6 @@
"unicons/anthropic-logo",
"unicons/cursor-logo",
"unicons/github-copilot-logo",
"unicons/github-enterprise",
"unicons/robot"
]

View file

@ -36,7 +36,7 @@ import { type RepositoryFormData } from '../types';
import { dataToSpec, deriveSigningKeySecret } from '../utils/data';
import { extractFormErrors, getConfigFormErrors } from '../utils/getFormErrors';
import { getHasTokenInstructions } from '../utils/git';
import { getRepositoryTypeConfig, isGitProvider } from '../utils/repositoryTypes';
import { getRepositoryTypeConfig, isGitHubBased, isGitProvider } from '../utils/repositoryTypes';
import { BranchOptionsSection } from './BranchOptionsSection';
import { CommitOptionsSection } from './CommitOptionsSection';
@ -93,13 +93,13 @@ export function ConfigForm({ data }: ConfigFormProps) {
// Repositories using GitHub App have a connection reference in their spec,
// whereas PAT-based repositories store credentials directly
const connectionName = data?.spec?.connection?.name;
const usesGitHubApp = Boolean(connectionName && type === 'github');
const usesGitHubApp = Boolean(connectionName && isGitHubBased(type));
const {
options: connectionOptions,
isLoading: connectionsLoading,
connections,
} = useConnectionOptions(usesGitHubApp);
} = useConnectionOptions(usesGitHubApp, isGitHubBased(type) ? type : undefined);
const selectedConnection = connections.find((c) => c.metadata?.name === watchedConnectionName);
const connectionWebhookDisabled = Boolean(selectedConnection?.spec?.webhook?.disabled);
@ -291,7 +291,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
/>
</Field>
)}
{hasTokenInstructions && <TokenPermissionsInfo type={type} />}
{hasTokenInstructions && <TokenPermissionsInfo type={type} url={watch('url')} />}
<Field
noMargin
label={gitFields.urlConfig.label}
@ -428,7 +428,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
)}
</>
)}
{type === 'github' && (
{isGitHubBased(type) && (
<WebhookSection<RepositoryFormData>
register={register}
control={control}

View file

@ -8,6 +8,7 @@ import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alph
import { checkImageRenderer, checkImageRenderingAllowed, checkPublicAccess } from '../GettingStarted/features';
import { type RepoType } from '../Wizard/types';
import { isGitHubBased } from '../utils/repositoryTypes';
import { DashboardPreviewField } from './DashboardPreviewField';
@ -41,7 +42,9 @@ export function PullRequestOptionsSection<T extends FieldValues>({
}: Props<T>) {
const gitConventionsEnabled = useBooleanFlagValue('provisioning.gitConventions', false);
// Previews are GitHub-only, so skip the settings query for other providers.
const settings = useGetFrontendSettingsQuery(!dashboardPreviewName || repoType !== 'github' ? skipToken : undefined);
const settings = useGetFrontendSettingsQuery(
!dashboardPreviewName || !isGitHubBased(repoType) ? skipToken : undefined
);
// Dashboard previews currently apply only to GitHub and require image rendering to be allowed.
const showDashboardPreviews = Boolean(

View file

@ -1,6 +1,7 @@
import { HttpResponse, delay, http } from 'msw';
import { render, screen, waitFor } from 'test/test-utils';
import { mockComboboxRect } from '@grafana/test-utils';
import { PROVISIONING_API_BASE as BASE } from '@grafana/test-utils/handlers';
import server from '@grafana/test-utils/server';
import { type Connection } from 'app/api/clients/provisioning/v0alpha1';
@ -70,6 +71,29 @@ describe('ConnectionForm', () => {
expect(screen.getByLabelText(/^Private Key \(PEM\)/)).toBeInTheDocument();
});
it('should allow selecting GitHub Enterprise when creating and it is available', async () => {
mockComboboxRect();
server.use(
http.get(`${BASE}/settings`, () =>
HttpResponse.json({ availableRepositoryTypes: ['github', 'githubEnterprise'] })
)
);
const { user } = setup();
const providerField = screen.getByLabelText(/^Provider/);
await waitFor(() => {
expect(providerField).toBeEnabled();
});
await user.click(providerField);
await user.click(await screen.findByRole('option', { name: 'GitHub Enterprise' }));
expect(providerField).toHaveDisplayValue('GitHub Enterprise');
// Selecting GitHub Enterprise reveals the GHE-only server URL field
expect(await screen.findByLabelText(/^Custom server URL/)).toBeInTheDocument();
});
it('should render Save button', () => {
setup();
@ -110,6 +134,35 @@ describe('ConnectionForm', () => {
});
});
describe('GitHub Enterprise', () => {
const createEnterpriseConnection = () =>
createMockConnection({
spec: {
title: 'Test GHE Connection',
type: 'githubEnterprise',
url: 'https://ghe.example.com/settings/installations/12345678',
githubEnterprise: {
appID: '123456',
installationID: '12345678',
serverUrl: 'https://ghe.example.com',
},
},
});
it('should render the server URL field when the connection type is githubEnterprise', () => {
setup({ data: createEnterpriseConnection() });
expect(screen.getByLabelText(/^Custom server URL/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Custom server URL/)).toHaveValue('https://ghe.example.com');
});
it('should not render the server URL field for a github connection', () => {
setup({ data: createMockConnection() });
expect(screen.queryByLabelText(/^Custom server URL/)).not.toBeInTheDocument();
});
});
describe('Form Validation', () => {
it('should show required error and not submit when fields are empty', async () => {
const { user } = setup();

View file

@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom-v5-compat';
import { t } from '@grafana/i18n';
import { isFetchError, reportInteraction } from '@grafana/runtime';
import { Alert, Button, Combobox, Field, Stack } from '@grafana/ui';
import { type Connection } from 'app/api/clients/provisioning/v0alpha1';
import { type Connection, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { extractErrorMessage } from 'app/api/utils';
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
@ -15,6 +15,7 @@ import { CONNECTIONS_TAB_URL } from '../constants';
import { useCreateOrUpdateConnection } from '../hooks/useCreateOrUpdateConnection';
import { type ConnectionFormData } from '../types';
import { extractFormErrors, getConnectionFormErrors } from '../utils/getFormErrors';
import { isGitHubBased } from '../utils/repositoryTypes';
import { DeleteConnectionButton } from './DeleteConnectionButton';
@ -22,8 +23,6 @@ interface ConnectionFormProps {
data?: Connection;
}
const providerOptions = [{ value: 'github', label: 'GitHub' }];
export function ConnectionForm({ data }: ConnectionFormProps) {
const connectionName = data?.metadata?.name;
const isEdit = Boolean(connectionName);
@ -31,16 +30,39 @@ export function ConnectionForm({ data }: ConnectionFormProps) {
const [submitData, request] = useCreateOrUpdateConnection(connectionName);
const navigate = useNavigate();
const { data: frontendSettings } = useGetFrontendSettingsQuery();
const availableTypes = frontendSettings?.availableRepositoryTypes ?? [];
const providerOptions = [
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
{ value: 'github', label: 'GitHub' },
...(availableTypes.includes('githubEnterprise')
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
[{ value: 'githubEnterprise', label: 'GitHub Enterprise' }]
: []),
];
const formMethods = useForm<ConnectionFormData>({
defaultValues: {
type: data?.spec?.type || 'github',
title: data?.spec?.title || '',
description: data?.spec?.description || '',
appID: data?.spec?.github?.appID || '',
installationID: data?.spec?.github?.installationID || '',
privateKey: '',
webhookDisabled: data?.spec?.webhook?.disabled ?? false,
},
defaultValues:
data?.spec?.type === 'githubEnterprise'
? {
type: 'githubEnterprise',
title: data?.spec?.title || '',
description: data?.spec?.description || '',
appID: data?.spec?.githubEnterprise?.appID || '',
installationID: data?.spec?.githubEnterprise?.installationID || '',
privateKey: '',
webhookDisabled: data?.spec?.webhook?.disabled ?? false,
serverUrl: data?.spec?.githubEnterprise?.serverUrl || '',
}
: {
type: 'github',
title: data?.spec?.title || '',
description: data?.spec?.description || '',
appID: data?.spec?.github?.appID || '',
installationID: data?.spec?.github?.installationID || '',
privateKey: '',
webhookDisabled: data?.spec?.webhook?.disabled ?? false,
},
});
const {
@ -48,11 +70,14 @@ export function ConnectionForm({ data }: ConnectionFormProps) {
reset,
register,
control,
watch,
formState: { isDirty, errors },
getValues,
setError,
} = formMethods;
const selectedType = watch('type');
useEffect(() => {
if (request.isSuccess) {
const formData = getValues();
@ -86,11 +111,21 @@ export function ConnectionForm({ data }: ConnectionFormProps) {
title: form.title,
type: form.type,
...(form.description && { description: form.description }),
github: {
appID: form.appID,
installationID: form.installationID,
},
...(form.webhookDisabled ? { webhook: { disabled: true } } : {}),
...(form.type === 'githubEnterprise'
? {
githubEnterprise: {
appID: form.appID,
installationID: form.installationID,
serverUrl: form.serverUrl,
},
}
: {
github: {
appID: form.appID,
installationID: form.installationID,
},
}),
};
await submitData(spec, form.privateKey);
@ -138,7 +173,7 @@ export function ConnectionForm({ data }: ConnectionFormProps) {
render={({ field: { ref, onChange, ...field } }) => (
<Combobox
id="type"
disabled // TODO enable when other providers are supported
disabled={isEdit || providerOptions.length <= 1}
options={providerOptions}
onChange={(option) => onChange(option?.value)}
{...field}
@ -147,7 +182,9 @@ export function ConnectionForm({ data }: ConnectionFormProps) {
/>
</Field>
<GitHubConnectionFields required={!isEdit} privateKeyConfigured={Boolean(privateKey)} />
{isGitHubBased(selectedType) && (
<GitHubConnectionFields required={!isEdit} privateKeyConfigured={Boolean(privateKey)} type={selectedType} />
)}
<WebhookDisabledField
registration={register('webhookDisabled')}

View file

@ -25,7 +25,7 @@ export function ConnectionListItem({ connection, isSelected, onClick }: Props) {
return (
<Card noMargin key={name} isSelected={isSelected} onClick={onClick}>
<Card.Figure>
<RepoIcon type={providerType} />
<RepoIcon type={providerType} autoHeight />
</Card.Figure>
<Card.Heading>
<Stack gap={2} direction="row" alignItems="center">

View file

@ -1,39 +1,81 @@
import { getAuthorProfileUrl } from './FileHistoryPage';
describe('getAuthorProfileUrl', () => {
it('returns GitHub URL for github type', () => {
expect(getAuthorProfileUrl('github', 'octocat')).toBe('https://github.com/octocat');
it('derives the host from the repo URL for github', () => {
expect(getAuthorProfileUrl('github', 'octocat', 'https://github.com/owner/repo')).toBe(
'https://github.com/octocat'
);
});
it('returns GitHub URL for githubEnterprise type', () => {
expect(getAuthorProfileUrl('githubEnterprise', 'octocat')).toBe('https://github.com/octocat');
it('derives the host from the repo URL for githubEnterprise (GHES/GHE Cloud)', () => {
expect(getAuthorProfileUrl('githubEnterprise', 'octocat', 'https://ghes.example.com/owner/repo')).toBe(
'https://ghes.example.com/octocat'
);
});
it('returns GitLab URL for gitlab type', () => {
expect(getAuthorProfileUrl('gitlab', 'user')).toBe('https://gitlab.com/user');
it('derives the host from the repo URL for gitlab', () => {
expect(getAuthorProfileUrl('gitlab', 'user', 'https://gitlab.com/group/repo')).toBe('https://gitlab.com/user');
});
it('returns Bitbucket URL for bitbucket type', () => {
expect(getAuthorProfileUrl('bitbucket', 'user')).toBe('https://bitbucket.org/user');
it('derives the host from the repo URL for bitbucket', () => {
expect(getAuthorProfileUrl('bitbucket', 'user', 'https://bitbucket.org/owner/repo')).toBe(
'https://bitbucket.org/user'
);
});
it('returns undefined for git type', () => {
expect(getAuthorProfileUrl('git', 'user')).toBeUndefined();
it('uses the self-managed host for gitlab, not gitlab.com', () => {
expect(getAuthorProfileUrl('gitlab', 'user', 'https://gitlab.internal.corp/group/repo')).toBe(
'https://gitlab.internal.corp/user'
);
});
it('uses the self-managed (Data Center) host for bitbucket, not bitbucket.org', () => {
expect(getAuthorProfileUrl('bitbucket', 'user', 'https://bitbucket.internal.corp/scm/proj/repo')).toBe(
'https://bitbucket.internal.corp/user'
);
});
it('preserves a non-default port from the repo URL', () => {
expect(getAuthorProfileUrl('githubEnterprise', 'octocat', 'https://ghes.example.com:8443/owner/repo')).toBe(
'https://ghes.example.com:8443/octocat'
);
});
it('drops the repo path and only keeps the host origin', () => {
expect(getAuthorProfileUrl('github', 'octocat', 'https://github.com/owner/repo/deep/path.json')).toBe(
'https://github.com/octocat'
);
});
it('returns undefined for git type even with a repo URL', () => {
expect(getAuthorProfileUrl('git', 'user', 'https://git.example.com/owner/repo.git')).toBeUndefined();
});
it('returns undefined for local type', () => {
expect(getAuthorProfileUrl('local', 'user')).toBeUndefined();
expect(getAuthorProfileUrl('local', 'user', 'https://example.com/owner/repo')).toBeUndefined();
});
it('returns undefined for undefined type', () => {
expect(getAuthorProfileUrl(undefined, 'user')).toBeUndefined();
expect(getAuthorProfileUrl(undefined, 'user', 'https://github.com/owner/repo')).toBeUndefined();
});
it('returns undefined when no repo URL is provided', () => {
expect(getAuthorProfileUrl('github', 'user')).toBeUndefined();
});
it('returns undefined for an invalid repo URL', () => {
expect(getAuthorProfileUrl('github', 'user', 'not-a-url')).toBeUndefined();
});
it('encodes special characters in username', () => {
expect(getAuthorProfileUrl('github', 'user name&special')).toBe('https://github.com/user%20name%26special');
expect(getAuthorProfileUrl('github', 'user name&special', 'https://github.com/owner/repo')).toBe(
'https://github.com/user%20name%26special'
);
});
it('encodes slashes in username', () => {
expect(getAuthorProfileUrl('gitlab', 'org/user')).toBe('https://gitlab.com/org%2Fuser');
expect(getAuthorProfileUrl('gitlab', 'org/user', 'https://gitlab.com/group/repo')).toBe(
'https://gitlab.com/org%2Fuser'
);
});
});

View file

@ -13,6 +13,7 @@ import { isNotFoundError } from 'app/features/alerting/unified/api/util';
import { PROVISIONING_URL } from '../constants';
import { type HistoryListResponse } from '../types';
import { getRemoteConfig } from '../utils/git';
import { formatTimestamp } from '../utils/time';
import { isFileHistorySupported } from './utils';
@ -25,6 +26,7 @@ export default function FileHistoryPage() {
const repoType = urlParams.get('repo_type');
const historyNotSupported = !isFileHistorySupported(repoType);
const query = useGetRepositoryStatusQuery({ name });
const repoUrl = getRemoteConfig(query.data?.spec)?.url;
const history = useGetRepositoryHistoryWithPathQuery(historyNotSupported ? skipToken : { name, path });
const notFound = (query.isError && isNotFoundError(query.error)) || historyNotSupported;
@ -58,8 +60,14 @@ export default function FileHistoryPage() {
) : (
<div>
{history.data ? (
//@ts-expect-error TODO fix history response types
<HistoryView history={history.data} path={path} repo={name} repoType={repoType ?? undefined} />
<HistoryView
//@ts-expect-error OpenAPI generated response type is `string`; actual payload is HistoryList - causing type discrepancy error
history={history.data}
path={path}
repo={name}
repoType={repoType ?? undefined}
repoUrl={repoUrl}
/>
) : (
<Spinner />
)}
@ -75,9 +83,10 @@ interface Props {
path: string;
repo: string;
repoType?: string;
repoUrl?: string;
}
function HistoryView({ history, path, repo, repoType }: Props) {
function HistoryView({ history, path, repo, repoType, repoUrl }: Props) {
if (!history.items) {
return <Trans i18nKey="provisioning.history-view.not-found">Not found</Trans>;
}
@ -97,7 +106,7 @@ function HistoryView({ history, path, repo, repoType }: Props) {
<Card.Description>
<Stack>
{item.authors.map((a) => {
const profileUrl = getAuthorProfileUrl(repoType, a.username);
const profileUrl = getAuthorProfileUrl(repoType, a.username, repoUrl);
return (
<span key={a.username} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{a.avatarURL && (
@ -127,17 +136,17 @@ function HistoryView({ history, path, repo, repoType }: Props) {
);
}
export function getAuthorProfileUrl(repoType: string | undefined, username: string): string | undefined {
const encoded = encodeURIComponent(username);
switch (repoType) {
case 'github':
case 'githubEnterprise':
return `https://github.com/${encoded}`;
case 'gitlab':
return `https://gitlab.com/${encoded}`;
case 'bitbucket':
return `https://bitbucket.org/${encoded}`;
default:
return undefined;
export function getAuthorProfileUrl(
repoType: string | undefined,
username: string,
repoUrl?: string
): string | undefined {
if (!repoUrl || !isFileHistorySupported(repoType)) {
return undefined;
}
try {
return `${new URL(repoUrl).origin}/${encodeURIComponent(username)}`;
} catch {
return undefined;
}
}

View file

@ -1,4 +1,4 @@
export function isFileHistorySupported(repoType?: string | null): boolean {
const supportedRepoTypes = new Set(['github', 'gitlab', 'bitbucket']);
const supportedRepoTypes = new Set(['github', 'githubEnterprise', 'gitlab', 'bitbucket']);
return !!repoType && supportedRepoTypes.has(repoType);
}

View file

@ -63,7 +63,7 @@ export function RepositoryListItem({ repository }: Props) {
return (
<Card noMargin key={name} className={styles.card}>
<Card.Figure className={styles.figure}>
<RepoIcon type={spec?.type} />
<RepoIcon type={spec?.type} autoHeight />
</Card.Figure>
<Card.Heading>
<Stack gap={2} direction="row" alignItems="center" wrap>

View file

@ -23,6 +23,7 @@ import { QuotaLimitNote } from '../Shared/QuotaLimitNote';
import { MissingFolderMetadataBanner } from '../components/Folders/MissingFolderMetadataBanner';
import { hasMissingFolderMetadata } from '../utils/folderMetadata';
import { isQuotaReachedOrExceeded } from '../utils/quota';
import { isGitHubBased } from '../utils/repositoryTypes';
import { getKindInfoByStat, getRepositoryRoute } from '../utils/resourceKinds';
import { formatTimestamp } from '../utils/time';
@ -238,8 +239,9 @@ const getStyles = (theme: GrafanaTheme2) => {
function getWebhookURL(repo: Repository) {
const { status, spec } = repo;
if (spec?.type === 'github' && status?.webhook?.url && spec.github?.url) {
return textUtil.sanitizeUrl(`${spec.github.url}/settings/hooks/${status.webhook?.id}`);
const repoUrl = spec?.github?.url ?? spec?.githubEnterprise?.url;
if (isGitHubBased(spec?.type) && status?.webhook?.url && repoUrl) {
return textUtil.sanitizeUrl(`${repoUrl}/settings/hooks/${status.webhook?.id}`);
}
return undefined;
}

View file

@ -83,6 +83,27 @@ describe('RepositoryPullStatusCard', () => {
expect(screen.getByText('/var/lib/grafana/repos/test')).toBeInTheDocument();
});
it('should display source info for GitHub Enterprise repos', () => {
const repo = createMockRepository({
spec: {
title: 'Test',
type: 'githubEnterprise',
githubEnterprise: { url: 'https://ghe.example.com/owner/repo', branch: 'develop', path: 'grafana/' },
sync: { target: 'folder', enabled: true },
workflows: [],
},
});
render(<RepositoryPullStatusCard repo={repo} />);
expect(screen.getByText('Repository URL:')).toBeInTheDocument();
const link = screen.getByRole('link', { name: /owner\/repo/ });
expect(link).toHaveAttribute('href', expect.stringContaining('ghe.example.com/owner/repo'));
expect(screen.getByText('Branch:')).toBeInTheDocument();
expect(screen.getByText('develop')).toBeInTheDocument();
expect(screen.getByText('Path:')).toBeInTheDocument();
expect(screen.getByText('grafana/')).toBeInTheDocument();
});
it('should display source info for GitLab repos', () => {
const repo = createMockRepository({
spec: {

View file

@ -3,10 +3,10 @@ import { css, cx } from '@emotion/css';
import { type GrafanaTheme2 } from '@grafana/data/';
import { t, Trans } from '@grafana/i18n';
import { Badge, Card, Grid, Text, TextLink, useStyles2 } from '@grafana/ui';
import { type Repository, type RepositorySpec } from 'app/api/clients/provisioning/v0alpha1';
import { type Repository } from 'app/api/clients/provisioning/v0alpha1';
import { MessageList } from '../Shared/MessageList';
import { formatRepoUrl, getRepoCommitUrl, getRepoHrefForProvider } from '../utils/git';
import { formatRepoUrl, getRemoteConfig, getRepoCommitUrl, getRepoHrefForProvider } from '../utils/git';
import { getStatusColor, getStatusIcon } from '../utils/repositoryStatus';
import { formatTimestamp } from '../utils/time';
@ -170,18 +170,3 @@ const getStyles = (theme: GrafanaTheme2) => {
}),
};
};
function getRemoteConfig(spec?: RepositorySpec) {
switch (spec?.type) {
case 'github':
return spec.github;
case 'gitlab':
return spec.gitlab;
case 'bitbucket':
return spec.bitbucket;
case 'git':
return spec.git;
default:
return undefined;
}
}

View file

@ -64,7 +64,7 @@ export default function RepositoryStatusPage() {
}}
renderTitle={(title) => (
<Stack alignItems="center">
<RepoIcon type={data?.spec?.type} />
<RepoIcon type={data?.spec?.type} autoHeight />
<Text element="h1">{title}</Text>
</Stack>
)}

View file

@ -155,7 +155,7 @@ export function ResourceTreeView({ repo }: ResourceTreeViewProps) {
let sourceLink: string | undefined = undefined;
if (item.hasFile && repo.spec?.type) {
const spec = repo.spec;
const config = spec.github || spec.gitlab || spec.bitbucket;
const config = spec.github || spec.githubEnterprise || spec.gitlab || spec.bitbucket;
if (config) {
const rawSourceLink = getRepoFileUrl({
repoType: spec.type,

View file

@ -5,6 +5,7 @@ import { type InstructionAvailability } from '../Wizard/types';
const PROVIDER_LINKS: Record<InstructionAvailability, string> = {
github: 'https://docs.github.com/en/authentication/managing-commit-signature-verification',
githubEnterprise: 'https://docs.github.com/en/authentication/managing-commit-signature-verification',
gitlab: 'https://docs.gitlab.com/user/project/repository/signed_commits/',
bitbucket: 'https://confluence.atlassian.com/bitbucketserver/verify-commit-signatures-1279066267.html',
};

View file

@ -6,7 +6,17 @@ import { getSvgSize } from '@grafana/ui/internal';
import { type RepoType } from '../Wizard/types';
import { getRepositoryTypeConfig } from '../utils/repositoryTypes';
export function RepoIcon({ type }: { type: RepoType | undefined }) {
interface RepoIconProps {
type: RepoType | undefined;
/**
* Let the icon height grow from the xxl width instead of being constrained to a square box.
* Use for icons whose viewBox is taller than square (e.g. github-enterprise stacks a wordmark
* under the octocat) so the logo renders at the same size as the plain square icons.
*/
autoHeight?: boolean;
}
export function RepoIcon({ type, autoHeight }: RepoIconProps) {
const styles = useStyles2(getStyles);
const config = type ? getRepositoryTypeConfig(type) : undefined;
@ -18,7 +28,7 @@ export function RepoIcon({ type }: { type: RepoType | undefined }) {
{config.logo ? (
<img src={config.logo} alt={config.label} className={styles.logo} />
) : (
<Icon name={config.icon} size="xxl" />
<Icon name={config.icon} size="xxl" style={autoHeight ? { height: 'auto' } : undefined} />
)}
</>
);

View file

@ -41,7 +41,7 @@ export function RepositoryTypeCards({ disabled }: RepositoryTypeCardsProps) {
>
<Card.Heading>
<Stack gap={2} alignItems="center">
<RepoIcon type={config.type} />
<RepoIcon type={config.type} autoHeight />
<Trans
i18nKey="provisioning.repository-type-cards.configure-with-provider"
values={{ provider: config.label }}
@ -84,7 +84,7 @@ export function RepositoryTypeCards({ disabled }: RepositoryTypeCardsProps) {
>
<Card.Heading>
<Stack gap={2} alignItems="center">
<RepoIcon type={config.type} />
<RepoIcon type={config.type} autoHeight />
{config.type === 'local' ? (
<Trans i18nKey="provisioning.repository-type-cards.configure-file">
Configure file provisioning

View file

@ -6,19 +6,26 @@ import { Stack, TextLink, useStyles2 } from '@grafana/ui';
import { type InstructionAvailability } from '../Wizard/types';
export function TokenPermissionsInfo({ type }: { type: InstructionAvailability }) {
export function TokenPermissionsInfo({ type, url }: { type: InstructionAvailability; url?: string }) {
const styles = useStyles2(getStyles);
const { tokenText, createTokenLink, createTokenButtonText } = connectStepInstruction()[type];
const { tokenText, createTokenLink, createTokenButtonText } = connectStepInstruction({
type,
serverUrl: getServerOrigin(url),
});
return (
<div className={styles.container}>
<Stack gap={0.5} wrap={'wrap'}>
<Trans i18nKey="provisioning.token-permissions-info.go-to">Go to</Trans>
<TextLink external href={createTokenLink}>
{tokenText}
</TextLink>
{createTokenLink ? (
<TextLink external href={createTokenLink}>
{tokenText}
</TextLink>
) : (
<strong>&nbsp;{tokenText}</strong>
)}
<Trans i18nKey="provisioning.token-permissions-info.and-click">and click</Trans>
<strong>"{createTokenButtonText}".</strong>
<strong>&quot;{createTokenButtonText}&quot;.</strong>
<Trans i18nKey="provisioning.token-permissions-info.make-sure">Create a token with these permissions</Trans>:
</Stack>
@ -65,9 +72,22 @@ type Permission = {
access: string;
};
// Extract the scheme + host (e.g. https://ghes.example.com) from a GitHub Enterprise repository URL.
function getServerOrigin(url?: string): string {
if (!url) {
return '';
}
try {
return new URL(url).origin;
} catch {
return '';
}
}
function getPermissionsForProvider(type: InstructionAvailability): Permission[] {
switch (type) {
case 'github':
case 'githubEnterprise':
// GitHub UI is English only, so these strings are not translated
return [
{ name: 'Contents', access: 'Read and write' },
@ -120,26 +140,38 @@ function AccessLevelField({ label, access }: { label: string; access: string })
);
}
function connectStepInstruction() {
return {
bitbucket: {
createTokenLink: 'https://id.atlassian.com/manage-profile/security/api-tokens',
tokenText: t('provisioning.token-permissions-info.bitbucket.token-text', 'Bitbucket API tokens'),
createTokenButtonText: t(
'provisioning.token-permissions-info.bitbucket.create-token-button',
'Create API Token with scopes'
),
},
gitlab: {
createTokenLink: 'https://gitlab.com/-/user_settings/personal_access_tokens',
tokenText: t('provisioning.token-permissions-info.gitlab.token-text', 'GitLab Personal Access Token'),
createTokenButtonText: t('provisioning.token-permissions-info.gitlab.create-token-button', 'Add new token'),
},
// GitHub UI is English only, so these strings are not translated
github: {
createTokenLink: 'https://github.com/settings/personal-access-tokens/new',
tokenText: 'GitHub Personal Access Token',
createTokenButtonText: 'Fine-grained token',
},
};
function connectStepInstruction({ type, serverUrl }: { type: InstructionAvailability; serverUrl?: string }) {
switch (type) {
case 'bitbucket':
return {
createTokenLink: 'https://id.atlassian.com/manage-profile/security/api-tokens',
tokenText: t('provisioning.token-permissions-info.bitbucket.token-text', 'Bitbucket API tokens'),
createTokenButtonText: t(
'provisioning.token-permissions-info.bitbucket.create-token-button',
'Create API Token with scopes'
),
};
case 'gitlab':
return {
createTokenLink: 'https://gitlab.com/-/user_settings/personal_access_tokens',
tokenText: t('provisioning.token-permissions-info.gitlab.token-text', 'GitLab Personal Access Token'),
createTokenButtonText: t('provisioning.token-permissions-info.gitlab.create-token-button', 'Add new token'),
};
case 'githubEnterprise':
// GitHub Enterprise hosts the token settings on its own server, derived from the repository URL.
// GitHub UI is English only, so these strings are not translated.
return {
createTokenButtonText: 'Fine-grained token',
createTokenLink: serverUrl ? `${serverUrl}/settings/personal-access-tokens/new` : '',
tokenText: 'Settings -> Personal Access Tokens',
};
case 'github':
default:
// GitHub UI is English only, so these strings are not translated
return {
createTokenLink: 'https://github.com/settings/personal-access-tokens/new',
tokenText: 'GitHub Personal Access Token',
createTokenButtonText: 'Fine-grained token',
};
}
}

View file

@ -7,6 +7,7 @@ import { Trans, t } from '@grafana/i18n';
import { Alert, Field, RadioButtonGroup, Stack, TextLink, useStyles2 } from '@grafana/ui';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { isGitHubBased } from '../utils/repositoryTypes';
import { GitHubAppFields } from './GitHubAppFields';
import { RepositoryField } from './components/RepositoryField';
@ -56,7 +57,7 @@ export function AuthTypeStep({ onGitHubAppSubmit }: AuthTypeStepProps) {
]);
const authTypeOptions = useMemo(() => getAuthTypeOptions(), []);
const shouldShowRepositories = githubAuthType !== 'github-app' || githubAppMode !== 'new';
const isGitHub = repoType === 'github';
const isGitHubBasedRepo = isGitHubBased(repoType);
const { isConnected: isSelectedConnectionReady } = useConnectionStatus(
githubAuthType === 'github-app' ? githubAppConnectionName : undefined
@ -89,20 +90,8 @@ export function AuthTypeStep({ onGitHubAppSubmit }: AuthTypeStepProps) {
</Alert>
)}
{isGitHub && (
<Alert
severity="info"
title={t('provisioning.wizard.github-enterprise-alert-title', 'GitHub Enterprise Server')}
>
<Trans i18nKey="provisioning.wizard.github-enterprise-alert-body">
GitHub Enterprise Server is currently only supported through the Pure Git repository type. Native GitHub
Enterprise integration is planned and will be available in the upcoming months.
</Trans>
</Alert>
)}
{/* PAT & Github App Switch - only for GitHub repositories */}
{isGitHub && (
{/* PAT & Github App Switch - only for GitHub / GitHub Enterprise repositories */}
{isGitHubBasedRepo && (
<Field
noMargin
label={t('provisioning.wizard.auth-type-label', 'Authentication method')}
@ -130,13 +119,13 @@ export function AuthTypeStep({ onGitHubAppSubmit }: AuthTypeStepProps) {
</Field>
)}
{githubAuthType === 'github-app' ? (
<GitHubAppFields onGitHubAppSubmit={onGitHubAppSubmit} />
{shouldShowRepositories && <RepositoryField isSelectedConnectionReady={isSelectedConnectionReady} />}
{isGitHubBased(repoType) && githubAuthType === 'github-app' ? (
<GitHubAppFields connectionType={repoType} onGitHubAppSubmit={onGitHubAppSubmit} />
) : (
<RepositoryTokenInput />
)}
{shouldShowRepositories && <RepositoryField isSelectedConnectionReady={isSelectedConnectionReady} />}
</Stack>
);
}

View file

@ -20,7 +20,7 @@ export function BootstrapStepCardIcons({ target, repoType }: { target: Target; r
<Stack>
<Icon name="folder" size="xxl" />
<Icon name="arrow-left" size="xxl" />
<RepoIcon type={repoType} />
<RepoIcon type={repoType} autoHeight />
</Stack>
);
}
@ -30,7 +30,7 @@ export function BootstrapStepCardIcons({ target, repoType }: { target: Target; r
<Stack>
<Icon name="apps" size="xxl" />
<Icon name="arrow-left" size="xxl" />
<RepoIcon type={repoType} />
<RepoIcon type={repoType} autoHeight />
</Stack>
);
}

View file

@ -11,7 +11,7 @@ import { EnablePushToConfiguredBranchOption } from '../Config/EnablePushToConfig
import { PullRequestOptionsSection } from '../Config/PullRequestOptionsSection';
import { WebhookSection } from '../Config/WebhookSection';
import { useConnectionList } from '../hooks/useConnectionList';
import { isGitProvider } from '../utils/repositoryTypes';
import { isGitHubBased, isGitProvider } from '../utils/repositoryTypes';
import { useStepStatus } from './StepStatusContext';
import { getGitProviderFields } from './fields';
@ -34,10 +34,9 @@ export const FinishStep = memo(function FinishStep() {
'githubAuthType',
]);
const isGithub = type === 'github';
const isGitBased = isGitProvider(type);
const [connections] = useConnectionList(isGithub && githubAuthType === 'github-app' ? {} : skipToken);
const [connections] = useConnectionList(isGitHubBased(type) && githubAuthType === 'github-app' ? {} : skipToken);
const connectionWebhookDisabled = useMemo(() => {
if (githubAuthType !== 'github-app' || !wizardConnectionName || !connections) {
return false;
@ -169,7 +168,7 @@ export const FinishStep = memo(function FinishStep() {
</>
)}
{isGithub && (
{isGitHubBased(type) && (
<WebhookSection<WizardFormData>
register={register}
control={control}

View file

@ -20,13 +20,14 @@ import { getConnectionFormErrors } from '../utils/getFormErrors';
import { useStepStatus } from './StepStatusContext';
import { GithubAppStepInstruction } from './components/GithubAppStepInstruction';
import { type ConnectionCreationResult, type WizardFormData } from './types';
import { type ConnectionCreationResult, type GitHubBasedConnectionType, type WizardFormData } from './types';
interface GitHubAppFieldsProps {
connectionType: GitHubBasedConnectionType;
onGitHubAppSubmit: (result: ConnectionCreationResult) => void;
}
export function GitHubAppFields({ onGitHubAppSubmit }: GitHubAppFieldsProps) {
export function GitHubAppFields({ connectionType, onGitHubAppSubmit }: GitHubAppFieldsProps) {
const styles = useStyles2(getStyles);
const {
control,
@ -34,20 +35,31 @@ export function GitHubAppFields({ onGitHubAppSubmit }: GitHubAppFieldsProps) {
setValue,
formState: { errors },
} = useFormContext<WizardFormData>();
const { setStepStatusInfo } = useStepStatus();
// GH app form
const credentialForm = useForm<ConnectionFormData>({
defaultValues: {
type: 'github',
title: '',
description: '',
appID: '',
installationID: '',
privateKey: '',
webhookDisabled: false,
},
defaultValues:
connectionType === 'githubEnterprise'
? {
type: 'githubEnterprise',
title: '',
description: '',
appID: '',
installationID: '',
privateKey: '',
webhookDisabled: false,
serverUrl: '',
}
: {
type: 'github',
title: '',
description: '',
appID: '',
installationID: '',
privateKey: '',
webhookDisabled: false,
},
});
const [createConnection, connectionRequest] = useCreateOrUpdateConnection();
@ -56,7 +68,7 @@ export function GitHubAppFields({ onGitHubAppSubmit }: GitHubAppFieldsProps) {
isLoading,
connections: githubConnections,
error: connectionListError,
} = useConnectionOptions(true);
} = useConnectionOptions(true, connectionType);
const hasNoConnections = !isLoading && !connectionListError && githubConnections.length === 0;
@ -79,14 +91,24 @@ export function GitHubAppFields({ onGitHubAppSubmit }: GitHubAppFieldsProps) {
return;
}
const { title, description, appID, installationID, privateKey, webhookDisabled } = credentialForm.getValues();
const spec: ConnectionSpec = {
type: 'github',
title,
...(description && { description }),
github: { appID, installationID },
...(webhookDisabled ? { webhook: { disabled: true } } : {}),
const form = credentialForm.getValues();
const baseSpec = {
title: form.title,
...(form.description && { description: form.description }),
...(form.webhookDisabled ? { webhook: { disabled: true } } : {}),
};
const spec: ConnectionSpec =
form.type === 'githubEnterprise'
? {
...baseSpec,
type: 'githubEnterprise',
githubEnterprise: { appID: form.appID, installationID: form.installationID, serverUrl: form.serverUrl },
}
: {
...baseSpec,
type: 'github',
github: { appID: form.appID, installationID: form.installationID },
};
const defaultErrorMessage = t(
'provisioning.wizard.github-app-creation-default-error',
@ -108,7 +130,7 @@ export function GitHubAppFields({ onGitHubAppSubmit }: GitHubAppFieldsProps) {
};
try {
const result = await createConnection(spec, privateKey);
const result = await createConnection(spec, form.privateKey);
if (result.data?.metadata?.name) {
credentialForm.reset();
onGitHubAppSubmit({ success: true, connectionName: result.data.metadata.name });
@ -221,6 +243,7 @@ export function GitHubAppFields({ onGitHubAppSubmit }: GitHubAppFieldsProps) {
<FormProvider {...credentialForm}>
<GitHubConnectionFields
required
type={connectionType}
onNewConnectionCreation={handleCreateConnection}
isCreating={connectionRequest.isLoading}
/>

View file

@ -49,14 +49,14 @@ async function typeIntoTokenField(user: UserEvent, placeholder: string, value: s
async function navigateToConnectionStep(
user: UserEvent,
type: 'github' | 'gitlab' | 'bitbucket' | 'local' | 'git',
type: 'github' | 'githubEnterprise' | 'gitlab' | 'bitbucket' | 'local' | 'git',
data?: {
token?: string;
tokenUser?: string;
url?: string;
}
) {
if (type === 'github') {
if (type === 'github' || type === 'githubEnterprise') {
// Select PAT option (GitHub App is the default)
await user.click(screen.getByLabelText(/Connect with Personal Access Token/i));
}
@ -64,6 +64,7 @@ async function navigateToConnectionStep(
if (type !== 'local' && data?.token) {
const tokenPlaceholders = {
github: 'ghp_xxxxxxxxxxxxxxxxxxxx',
githubEnterprise: 'ghp_xxxxxxxxxxxxxxxxxxxx',
gitlab: 'glpat-xxxxxxxxxxxxxxxxxxx',
bitbucket: 'ATATTxxxxxxxxxxxxxxxx',
git: 'token or password',
@ -93,7 +94,7 @@ async function navigateToConnectionStep(
async function fillConnectionForm(
user: UserEvent,
type: 'github' | 'gitlab' | 'bitbucket' | 'local' | 'git',
type: 'github' | 'githubEnterprise' | 'gitlab' | 'bitbucket' | 'local' | 'git',
data: {
token?: string;
tokenUser?: string;
@ -675,6 +676,54 @@ describe('ProvisioningWizard', () => {
});
describe('Different Repository Types', () => {
it('should render choose auth type step initially for GitHub Enterprise', async () => {
setup(<ProvisioningWizard type="githubEnterprise" />);
// GitHub Enterprise shares GitHub's auth flow: both PAT and GitHub App options
expect(await screen.findByRole('heading', { name: /Connect/i })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: /Connect with Personal Access Token/i })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: /Connect with GitHub App/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Configure repository$/i })).toBeInTheDocument();
});
it('should render GitHub Enterprise-specific fields', async () => {
const { user } = setup(<ProvisioningWizard type="githubEnterprise" />);
// Select PAT option (GitHub App is the default)
await user.click(screen.getByLabelText(/Connect with Personal Access Token/i));
// Auth step fields: GHE uses the GitHub PAT placeholder and a GHE-specific URL placeholder
expect(screen.getByPlaceholderText('ghp_xxxxxxxxxxxxxxxxxxxx')).toBeInTheDocument();
expect(
screen.getByPlaceholderText(
'https://your-enterprise-url.com/owner/repository or https://<slug>.ghe.com/owner/repository'
)
).toBeInTheDocument();
await navigateToConnectionStep(user, 'githubEnterprise', {
token: 'ghp_testtoken',
url: 'https://ghe.example.com/test/repo',
});
// Connection step fields (branch combobox + path combobox)
expect(screen.getAllByRole('combobox')).toHaveLength(2);
});
it('should skip sync step when there are no resources for GitHub Enterprise', async () => {
const { user } = setup(<ProvisioningWizard type="githubEnterprise" />);
await fillConnectionForm(user, 'githubEnterprise', {
token: 'ghp_testtoken',
url: 'https://ghe.example.com/test/repo',
});
await user.click(screen.getByRole('button', { name: /Choose what to synchronize/i }));
expect(await screen.findByRole('heading', { name: /3\. Choose what to synchronize/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Choose additional settings/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Synchronize with external storage/i })).not.toBeInTheDocument();
});
it('should render GitLab-specific fields', async () => {
const { user } = setup(<ProvisioningWizard type="gitlab" />);

View file

@ -14,6 +14,7 @@ import { getDefaultValues } from '../Config/defaults';
import { ProvisioningAlert } from '../Shared/ProvisioningAlert';
import { PROVISIONING_URL } from '../constants';
import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
import { isGitHubBased } from '../utils/repositoryTypes';
import { useStepStatus } from './StepStatusContext';
import { Stepper } from './Stepper';
@ -52,7 +53,7 @@ export const ProvisioningWizard = memo(function ProvisioningWizard({
migrate: {
history: true,
},
githubAuthType: type === 'github' ? 'github-app' : 'pat',
githubAuthType: isGitHubBased(type) ? 'github-app' : 'pat',
githubAppMode: 'existing',
githubApp: {},
},
@ -115,7 +116,7 @@ export const ProvisioningWizard = memo(function ProvisioningWizard({
activeStep === 'finish' && (isStepSuccess || completedSteps.includes('synchronize'));
const shouldUseCancelBehavior =
activeStep === 'authType' ||
(activeStep === 'connection' && repoType !== 'github') ||
(activeStep === 'connection' && !isGitHubBased(repoType)) ||
isSyncCompleted ||
isFinishWithSyncCompleted;

View file

@ -10,7 +10,7 @@ import { t } from '@grafana/i18n';
import { Combobox, Field, Input } from '@grafana/ui';
import { type ExternalRepository } from '../../types';
import { isGitProvider } from '../../utils/repositoryTypes';
import { isGitHubBased, isGitProvider } from '../../utils/repositoryTypes';
import { getGitProviderFields } from '../fields';
import { type WizardFormData } from '../types';
@ -34,7 +34,7 @@ export function RepositoryField({ isSelectedConnectionReady }: { isSelectedConne
]);
const isGitBased = isGitProvider(type);
const isGitHubAppAuth = type === 'github' && githubAuthType === 'github-app';
const isGitHubAppAuth = isGitHubBased(type) && githubAuthType === 'github-app';
const gitFields = isGitBased ? getGitProviderFields(type) : null;
const {
data: connectionRepositories,

View file

@ -15,11 +15,13 @@ export function RepositoryTokenInput() {
register,
control,
setValue,
watch,
formState: { errors },
getValues,
} = useFormContext<WizardFormData>();
const type = getValues('repository.type');
const url = watch('repository.url');
const isGitBased = isGitProvider(type);
const gitFields = isGitBased ? getGitProviderFields(type) : null;
const hasTokenInstructions = getHasTokenInstructions(type);
@ -30,7 +32,7 @@ export function RepositoryTokenInput() {
return (
<>
{hasTokenInstructions && <TokenPermissionsInfo type={type} />}
{hasTokenInstructions && <TokenPermissionsInfo type={type} url={url} />}
<Field
noMargin
label={gitFields.tokenConfig.label}

View file

@ -90,52 +90,61 @@ const getProviderConfigs = (): Record<RepoType, Record<string, FieldConfig>> =>
},
};
return {
github: {
token: {
label: t('provisioning.github.token-label', 'Personal Access Token'),
description: t(
'provisioning.github.token-description',
'GitHub Personal Access Token with repository permissions'
),
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder: 'ghp_xxxxxxxxxxxxxxxxxxxx',
required: true,
validation: {
required: t('provisioning.github.token-required', 'GitHub token is required'),
},
// GitHub and GitHub Enterprise share the same fields; only the URL placeholder host
// differs.
const github = (...hosts: string[]): Record<string, FieldConfig> => ({
token: {
label: t('provisioning.github.token-label', 'Personal Access Token'),
description: t(
'provisioning.github.token-description',
'GitHub Personal Access Token with repository permissions'
),
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder: 'ghp_xxxxxxxxxxxxxxxxxxxx',
required: true,
validation: {
required: t('provisioning.github.token-required', 'GitHub token is required'),
},
url: {
...shared.url,
description: t('provisioning.github.url-description', 'The GitHub repository URL'),
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder: 'https://github.com/owner/repository',
required: true,
validation: {
...shared.url.validation,
required: t('provisioning.github.url-required', 'Repository URL is required'),
},
},
branch: {
...shared.branch,
required: true,
validation: {
required: t('provisioning.github.branch-required', 'Branch is required'),
},
},
path: {
...shared.path,
required: false,
},
prWorkflow: {
label: t('provisioning.github.pr-workflow-label', 'Enable pull request option when saving'),
description: t(
'provisioning.github.pr-workflow-description', // trufflehog:ignore
'Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.'
),
},
...signingFields,
},
url: {
...shared.url,
description: t('provisioning.github.url-description', 'The GitHub repository URL'),
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder: hosts
.map((h) => {
return h + '/owner/repository';
})
.join(' or '),
required: true,
validation: {
...shared.url.validation,
required: t('provisioning.github.url-required', 'Repository URL is required'),
},
},
branch: {
...shared.branch,
required: true,
validation: {
required: t('provisioning.github.branch-required', 'Branch is required'),
},
},
path: {
...shared.path,
required: false,
},
prWorkflow: {
label: t('provisioning.github.pr-workflow-label', 'Enable pull request option when saving'),
description: t(
'provisioning.github.pr-workflow-description', // trufflehog:ignore
'Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.'
),
},
...signingFields,
});
return {
github: github('https://github.com'),
githubEnterprise: github('https://your-enterprise-url.com', 'https://<slug>.ghe.com'),
gitlab: {
token: {
label: t('provisioning.gitlab.token-label', 'Project Access Token'),
@ -317,7 +326,6 @@ const getProviderConfigs = (): Record<RepoType, Record<string, FieldConfig>> =>
},
},
},
githubEnterprise: {},
};
};

View file

@ -5,6 +5,7 @@ import { reportInteraction } from '@grafana/runtime';
import { PROVISIONING_URL } from '../../constants';
import { getWorkflows } from '../../utils/data';
import { isGitHubBased } from '../../utils/repositoryTypes';
import { type Step } from '../Stepper';
import { type RepoType, type StepStatusInfo, type WizardFormData, type WizardStep } from '../types';
@ -83,7 +84,7 @@ export function useWizardNavigation({
repositoryType: repoType,
target: syncTarget,
workflowsEnabled: getWorkflows(formData.repository),
...(repoType === 'github' && { githubAuthType }),
...(isGitHubBased(repoType) && { githubAuthType }),
});
// Navigate to repository status page instead of listing page
const repoName = formData.repositoryName;

View file

@ -7,6 +7,8 @@ export type WizardStep = 'authType' | 'githubApp' | 'connection' | 'bootstrap' |
export type RepoType = RepositorySpec['type'];
export type GitHubBasedConnectionType = 'github' | 'githubEnterprise';
export type GitHubAuthType = 'pat' | 'github-app';
export type GitHubAppMode = 'existing' | 'new';
@ -56,4 +58,4 @@ export type StepStatusInfo =
export type ConnectionCreationResult = { success: true; connectionName: string } | { success: false; error: string };
export type InstructionAvailability = Extract<RepoType, 'bitbucket' | 'gitlab' | 'github'>;
export type InstructionAvailability = Extract<RepoType, 'bitbucket' | 'gitlab' | 'github' | 'githubEnterprise'>;

View file

@ -4,6 +4,7 @@ import { Controller, useFormContext } from 'react-hook-form';
import { t, Trans } from '@grafana/i18n';
import { Button, Field, Input, SecretTextArea, Stack } from '@grafana/ui';
import { type GitHubBasedConnectionType } from '../../Wizard/types';
import { type ConnectionFormData } from '../../types';
import { validateNoHiddenCharacters } from '../../utils/validators';
@ -15,10 +16,12 @@ export interface GitHubConnectionFieldsProps {
onNewConnectionCreation?: () => void;
/** Whether the connection is currently being created */
isCreating?: boolean;
type: GitHubBasedConnectionType;
}
export const GitHubConnectionFields = memo<GitHubConnectionFieldsProps>(
({ required = true, privateKeyConfigured = false, onNewConnectionCreation, isCreating = false }) => {
({ required = true, privateKeyConfigured = false, onNewConnectionCreation, isCreating = false, type }) => {
const isEnterprise = type === 'githubEnterprise';
const [isPrivateKeyConfigured, setIsPrivateKeyConfigured] = useState(privateKeyConfigured);
const {
register,
@ -67,6 +70,29 @@ export const GitHubConnectionFields = memo<GitHubConnectionFieldsProps>(
/>
</Field>
{isEnterprise && (
<Field
noMargin
label={t('provisioning.github-enterprise.server-url-label', 'Custom server URL')}
description={t(
'provisioning.github-enterprise.server-url-description',
'The custom server URL where your GitHub Enterprise is hosted'
)}
invalid={!!errors.serverUrl}
error={errors.serverUrl?.message}
required={required}
>
<Input
id="serverUrl"
{...register('serverUrl', {
required: requiredValidation,
})}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="https://your-enterprise-url.com or https://<enterprise-slug>.ghe.com"
/>
</Field>
)}
<Field
noMargin
label={t('provisioning.connection-form.label-app-id', 'GitHub App ID')}

View file

@ -9,6 +9,7 @@ export const getBranchUrl = (baseUrl: string, branch: string, repoType?: string)
const cleanBaseUrl = buildCleanBaseUrl(baseUrl);
switch (repoType) {
case 'githubEnterprise':
case 'github':
return `${cleanBaseUrl}/tree/${branch}`;
case 'gitlab':

View file

@ -5,15 +5,19 @@ import { useAsync } from 'react-use';
import { t } from '@grafana/i18n';
import { useLazyGetConnectionRepositoriesQuery } from 'app/api/clients/provisioning/v0alpha1';
import { type GitHubBasedConnectionType } from '../Wizard/types';
import { type ExternalRepository } from '../types';
import { isConnectionReady } from '../utils/connectionStatus';
import { formatRepoUrl } from '../utils/git';
import { useConnectionList } from './useConnectionList';
export function useConnectionOptions(enabled: boolean) {
export function useConnectionOptions(enabled: boolean, connectionType?: GitHubBasedConnectionType) {
const [connections, connectionsLoading, error, refetch] = useConnectionList(enabled ? {} : skipToken);
const githubConnections = useMemo(() => connections?.filter((c) => c.spec?.type === 'github') ?? [], [connections]);
const githubConnections = useMemo(
() => connections?.filter((c) => c.spec?.type === connectionType) ?? [],
[connections, connectionType]
);
// Only fetch repos for ready connections
const connectionNames = useMemo(

View file

@ -2,7 +2,8 @@ import {
type BitbucketRepositoryConfig,
type BranchOptions,
type CommitOptions,
type ConnectionSpec,
type GitHubConnectionConfig,
type GitHubEnterpriseConnectionConfig,
type GitHubRepositoryConfig,
type GitLabRepositoryConfig,
type GitRepositoryConfig,
@ -41,19 +42,22 @@ export type RepositoryFormData = Omit<RepositorySpec, 'workflows' | 'branch' | R
branchOptions?: BranchOptions;
};
// Connection type definition - extracted from API client
type ConnectionType = ConnectionSpec['type'];
export type ConnectionFormData = {
type: ConnectionType;
// Base fields shared by all connection providers (excludes the `type` discriminant).
type ConnectionFormDataBase = {
title: string;
description: string;
appID: string;
installationID: string;
privateKey?: string;
webhookDisabled?: boolean;
};
type GitHubConnectionFormData = ConnectionFormDataBase &
GitHubConnectionConfig & { type: 'github'; serverUrl?: string };
type GitHubEnterpriseConnectionFormData = ConnectionFormDataBase &
GitHubEnterpriseConnectionConfig & { type: 'githubEnterprise' };
export type ConnectionFormData = GitHubConnectionFormData | GitHubEnterpriseConnectionFormData;
// Added to DashboardDTO to help editor
export interface ProvisioningPreview {
repo: string;

View file

@ -2,6 +2,8 @@ import { type CommitOptions, type InlineSecureValue, type RepositorySpec } from
import { type RepositoryFormData } from '../types';
import { isGitHubBased } from './repositoryTypes';
// Template field names across the git-convention option groups.
type TemplateFieldKey = 'singleResourceMessageTemplate' | 'nameTemplate' | 'titleTemplate';
@ -127,13 +129,12 @@ export const dataToSpec = (data: RepositoryFormData, connectionName?: string): R
...baseConfig,
generateDashboardPreviews: data.generateDashboardPreviews,
};
// Add connection reference at spec level if using GitHub App
// connection name is only available for the app flow
// Prefer data.connectionName over the parameter for consistency
const finalConnectionName = data.connectionName || connectionName;
if (finalConnectionName) {
spec.connection = { name: finalConnectionName };
}
break;
case 'githubEnterprise':
spec.githubEnterprise = {
...baseConfig,
generateDashboardPreviews: data.generateDashboardPreviews,
};
break;
case 'gitlab':
spec.gitlab = baseConfig;
@ -155,6 +156,16 @@ export const dataToSpec = (data: RepositoryFormData, connectionName?: string): R
break;
}
// Add connection reference at spec level when using GitHub App (github and
// githubEnterprise). The connection name is only available for the app flow;
// prefer data.connectionName over the parameter for consistency.
if (isGitHubBased(data.type)) {
const finalConnectionName = data.connectionName || connectionName;
if (finalConnectionName) {
spec.connection = { name: finalConnectionName };
}
}
// We need to deep clone the data, so it doesn't become immutable
return structuredClone(spec);
};
@ -178,7 +189,7 @@ export const deriveSigningKeySecret = (
};
export const specToData = (spec: RepositorySpec): RepositoryFormData => {
const remoteConfig = spec.github || spec.gitlab || spec.bitbucket || spec.git;
const remoteConfig = spec.github || spec.githubEnterprise || spec.gitlab || spec.bitbucket || spec.git;
// tokenUser is only available for bitbucket and pure git
const tokenUser = spec.bitbucket?.tokenUser ?? spec.git?.tokenUser;
@ -190,7 +201,8 @@ export const specToData = (spec: RepositorySpec): RepositoryFormData => {
branchOptions: spec.branch,
url: remoteConfig?.url || '',
tokenUser: tokenUser || '',
generateDashboardPreviews: spec.github?.generateDashboardPreviews || false,
generateDashboardPreviews:
spec.github?.generateDashboardPreviews || spec.githubEnterprise?.generateDashboardPreviews || false,
readOnly: !spec.workflows.length,
prWorkflow: spec.workflows.includes('branch'),
enablePushToConfiguredBranch: spec.workflows.includes('write'),
@ -205,6 +217,7 @@ export const specToData = (spec: RepositorySpec): RepositoryFormData => {
export const generateRepositoryTitle = (repository: Pick<RepositoryFormData, 'type' | 'url' | 'path'>): string => {
switch (repository.type) {
case 'github':
case 'githubEnterprise':
case 'gitlab':
case 'bitbucket':
case 'git': {

View file

@ -0,0 +1,41 @@
import { type ErrorDetails } from 'app/api/clients/provisioning/v0alpha1';
import { getConnectionFormErrors } from './getFormErrors';
describe('getConnectionFormErrors', () => {
it('maps GitHub Enterprise server URL errors to the serverUrl field', () => {
const errors: ErrorDetails[] = [
{ field: 'spec.githubEnterprise.serverUrl', detail: 'Invalid server URL', type: 'FieldValueInvalid' },
];
expect(getConnectionFormErrors(errors)).toEqual([['serverUrl', { message: 'Invalid server URL' }]]);
});
it('maps a bare serverUrl error to the serverUrl field', () => {
const errors: ErrorDetails[] = [
{ field: 'serverUrl', detail: 'Server URL is required', type: 'FieldValueRequired' },
];
expect(getConnectionFormErrors(errors)).toEqual([['serverUrl', { message: 'Server URL is required' }]]);
});
it('maps GitHub Enterprise appID and installationID errors to their fields', () => {
const errors: ErrorDetails[] = [
{ field: 'spec.githubEnterprise.appID', detail: 'Invalid App ID', type: 'FieldValueInvalid' },
{ field: 'spec.githubEnterprise.installationID', detail: 'Invalid Installation ID', type: 'FieldValueInvalid' },
];
expect(getConnectionFormErrors(errors)).toEqual([
['appID', { message: 'Invalid App ID' }],
['installationID', { message: 'Invalid Installation ID' }],
]);
});
it('still maps plain GitHub appID errors', () => {
const errors: ErrorDetails[] = [
{ field: 'spec.github.appID', detail: 'Invalid App ID', type: 'FieldValueInvalid' },
];
expect(getConnectionFormErrors(errors)).toEqual([['appID', { message: 'Invalid App ID' }]]);
});
});

View file

@ -155,8 +155,12 @@ export const getConnectionFormErrors = (data: ErrorDetails[] | Status): Connecti
description: 'description',
appID: 'appID',
installationID: 'installationID',
serverUrl: 'serverUrl',
'github.appID': 'appID',
'github.installationID': 'installationID',
'githubEnterprise.appID': 'appID',
'githubEnterprise.installationID': 'installationID',
'githubEnterprise.serverUrl': 'serverUrl',
'secure.privateKey': 'privateKey',
privateKey: 'privateKey',
'webhook.disabled': 'webhookDisabled',

View file

@ -87,6 +87,13 @@ export const getRepoHrefForProvider = (spec?: RepositorySpec) => {
providerSegments: ['tree'],
path: spec.github?.path,
});
case 'githubEnterprise':
return buildRepoUrl({
baseUrl: spec.githubEnterprise?.url,
branch: spec.githubEnterprise?.branch,
providerSegments: ['tree'],
path: spec.githubEnterprise?.path,
});
case 'gitlab':
return buildRepoUrl({
baseUrl: spec.gitlab?.url,
@ -111,7 +118,7 @@ export const getRepoHrefForProvider = (spec?: RepositorySpec) => {
};
export function getHasTokenInstructions(type: RepoType): type is InstructionAvailability {
return type === 'github' || type === 'gitlab' || type === 'bitbucket';
return type === 'github' || type === 'githubEnterprise' || type === 'gitlab' || type === 'bitbucket';
}
type GetRepoFileUrlParams = {
@ -201,6 +208,7 @@ export function getRepoRawFileUrl({
const cleanPath = stripSlashes(filePath);
switch (repoType) {
case 'githubEnterprise':
case 'github':
return buildRepoUrl({
baseUrl: url,
@ -252,6 +260,7 @@ export function getRepoEditFileUrl({
const fullPath = pathPrefix ? `${pathPrefix.replace(/\/+$/, '')}/${filePath}` : filePath;
switch (repoType) {
case 'githubEnterprise':
case 'github':
return buildRepoUrl({
baseUrl: url,
@ -301,6 +310,7 @@ export function getRepoNewFileUrl({
const fullPath = pathPrefix ? `${pathPrefix.replace(/\/+$/, '')}/${filePath}` : filePath;
switch (repoType) {
case 'githubEnterprise':
case 'github': {
const base = buildRepoUrl({
baseUrl: url,
@ -372,6 +382,17 @@ export function getRepoCommitUrl(spec?: RepositorySpec, commit?: string) {
});
}
break;
case 'githubEnterprise':
if (spec.githubEnterprise?.url) {
providerSegments = ['commit'];
url = buildRepoUrl({
baseUrl: spec.githubEnterprise.url,
branch: undefined,
providerSegments,
path: commit,
});
}
break;
case 'gitlab':
if (spec.gitlab?.url) {
providerSegments = ['-', 'commit'];
@ -398,3 +419,22 @@ export function getRepoCommitUrl(spec?: RepositorySpec, commit?: string) {
return { hasUrl: !!url, url };
}
// Returns the provider-specific config block (which carries `url`, `branch`, `path`)
// for whichever Git provider the repository uses.
export function getRemoteConfig(spec?: RepositorySpec) {
switch (spec?.type) {
case 'github':
return spec.github;
case 'githubEnterprise':
return spec.githubEnterprise;
case 'gitlab':
return spec.gitlab;
case 'bitbucket':
return spec.bitbucket;
case 'git':
return spec.git;
default:
return undefined;
}
}

View file

@ -37,6 +37,19 @@ const getRepositoryTypeConfigs = (): RepositoryTypeConfig[] => [
),
icon: 'github' as const,
},
{
type: 'githubEnterprise',
label: t('provisioning.repository-types.github-enterprise', 'GitHub Enterprise'),
description: t(
'provisioning.repository-types.github-enterprise-description',
'Connect to GitHub Enterprise Server or GitHub Enterprise Cloud repositories'
),
tooltip: t(
'provisioning.repository-types.github-enterprise-tooltip',
'Enhanced GitHub integration for self-hosted GitHub Enterprise Server or GitHub Enterprise Cloud with webhook-driven sync, PR comments, deep links to source files, and repository settings validations.'
),
icon: 'github-enterprise' as const,
},
{
type: 'gitlab',
label: t('provisioning.repository-types.gitlab', 'GitLab'),
@ -75,12 +88,18 @@ export const getRepositoryTypeConfig = (type: RepoType): RepositoryTypeConfig |
return getRepositoryTypeConfigs().find((config) => config.type === type);
};
const GIT_PROVIDER_TYPES = ['github', 'gitlab', 'bitbucket', 'git'];
const GIT_PROVIDER_TYPES = ['github', 'githubEnterprise', 'gitlab', 'bitbucket', 'git'];
export const isGitProvider = (type: RepoType) => {
return GIT_PROVIDER_TYPES.includes(type);
};
// GitHub and GitHub Enterprise share the same auth flow (GitHub App or PAT),
// connection handling, and deep-link URL structure.
export const isGitHubBased = (type?: RepoType): type is 'github' | 'githubEnterprise' => {
return type === 'github' || type === 'githubEnterprise';
};
/**
* Get repository configurations ordered by provider type priority:
* 1. Git providers first (github, gitlab, bitbucket) - excludes pure git

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 24 28.13"><path d="M12,2.2467A10.00042,10.00042,0,0,0,8.83752,21.73419c.5.08752.6875-.21247.6875-.475,0-.23749-.01251-1.025-.01251-1.86249C7,19.85919,6.35,18.78423,6.15,18.22173A3.636,3.636,0,0,0,5.125,16.8092c-.35-.1875-.85-.65-.01251-.66248A2.00117,2.00117,0,0,1,6.65,17.17169a2.13742,2.13742,0,0,0,2.91248.825A2.10376,2.10376,0,0,1,10.2,16.65923c-2.225-.25-4.55-1.11254-4.55-4.9375a3.89187,3.89187,0,0,1,1.025-2.6875,3.59373,3.59373,0,0,1,.1-2.65s.83747-.26251,2.75,1.025a9.42747,9.42747,0,0,1,5,0c1.91248-1.3,2.75-1.025,2.75-1.025a3.59323,3.59323,0,0,1,.1,2.65,3.869,3.869,0,0,1,1.025,2.6875c0,3.83747-2.33752,4.6875-4.5625,4.9375a2.36814,2.36814,0,0,1,.675,1.85c0,1.33752-.01251,2.41248-.01251,2.75,0,.26251.1875.575.6875.475A10.0053,10.0053,0,0,0,12,2.2467Z"/><path d="M1.36 22.81H3.76V23.20H1.80V24.33H3.53V24.70H1.80V25.90H3.76V26.30H1.36ZM4.19 23.78H4.60L4.62 24.31H4.64Q4.75 24.04 4.98 23.89Q5.20 23.74 5.48 23.74Q5.88 23.74 6.11 24.00Q6.34 24.26 6.34 24.80V26.30H5.92V24.89Q5.92 24.49 5.77 24.30Q5.63 24.10 5.33 24.10Q5.01 24.10 4.82 24.33Q4.62 24.56 4.62 24.93V26.30H4.19ZM7.16 25.75V24.13H6.69V23.78H7.16V23.04L7.59 22.98V23.78H8.15V24.13H7.59V25.67Q7.59 25.81 7.65 25.87Q7.71 25.94 7.84 25.94H8.16V26.30H7.71Q7.43 26.30 7.30 26.17Q7.16 26.04 7.16 25.75ZM8.43 25.05Q8.43 24.65 8.57 24.36Q8.71 24.06 8.98 23.90Q9.24 23.74 9.59 23.74Q9.98 23.74 10.24 23.91Q10.51 24.08 10.63 24.39Q10.76 24.71 10.74 25.12H8.85Q8.86 25.54 9.06 25.78Q9.26 26.01 9.61 26.01Q9.88 26.01 10.05 25.89Q10.23 25.77 10.29 25.55H10.71Q10.64 25.92 10.34 26.13Q10.05 26.34 9.61 26.34Q9.06 26.34 8.74 25.99Q8.43 25.64 8.43 25.05ZM10.31 24.82Q10.29 24.46 10.10 24.27Q9.91 24.07 9.59 24.07Q9.28 24.07 9.08 24.27Q8.88 24.47 8.86 24.82ZM11.19 23.78H11.58L11.60 24.25H11.61Q11.70 24.01 11.89 23.88Q12.09 23.75 12.35 23.75L12.46 23.75V24.16Q12.43 24.16 12.34 24.16Q12.02 24.16 11.83 24.33Q11.64 24.50 11.62 24.83V26.30H11.19ZM12.85 23.78H13.25L13.26 24.30H13.29Q13.38 24.04 13.60 23.89Q13.82 23.74 14.12 23.74Q14.43 23.74 14.65 23.89Q14.88 24.05 15.01 24.34Q15.13 24.64 15.13 25.04Q15.13 25.44 15.01 25.74Q14.88 26.03 14.65 26.19Q14.43 26.34 14.12 26.34Q13.82 26.34 13.60 26.20Q13.38 26.05 13.29 25.79H13.27L13.27 27.13H12.85ZM14.69 25.04Q14.69 24.60 14.51 24.35Q14.32 24.10 13.99 24.10Q13.65 24.10 13.46 24.33Q13.27 24.57 13.27 24.91V25.18Q13.27 25.52 13.46 25.75Q13.65 25.99 13.98 25.99Q14.31 25.99 14.50 25.74Q14.69 25.49 14.69 25.04ZM15.64 23.78H16.03L16.05 24.25H16.06Q16.15 24.01 16.34 23.88Q16.54 23.75 16.80 23.75L16.91 23.75V24.16Q16.88 24.16 16.79 24.16Q16.47 24.16 16.28 24.33Q16.09 24.50 16.07 24.83V26.30H15.64ZM17.29 23.78H17.72V26.30H17.29ZM17.22 23.06Q17.22 22.94 17.30 22.87Q17.38 22.79 17.51 22.79Q17.63 22.79 17.71 22.87Q17.79 22.94 17.79 23.06Q17.79 23.18 17.71 23.26Q17.63 23.34 17.51 23.34Q17.38 23.34 17.30 23.26Q17.22 23.18 17.22 23.06ZM18.17 25.56H18.59Q18.62 25.80 18.79 25.92Q18.95 26.03 19.23 26.03Q19.50 26.03 19.64 25.93Q19.79 25.82 19.79 25.63Q19.79 25.42 19.65 25.33Q19.52 25.24 19.17 25.19Q18.68 25.12 18.46 24.97Q18.25 24.82 18.25 24.48Q18.25 24.14 18.51 23.94Q18.77 23.73 19.20 23.73Q19.62 23.73 19.87 23.93Q20.12 24.13 20.15 24.48H19.73Q19.68 24.05 19.19 24.05Q18.95 24.05 18.81 24.16Q18.67 24.26 18.67 24.44Q18.67 24.62 18.81 24.71Q18.95 24.79 19.32 24.84Q19.78 24.91 20.00 25.07Q20.22 25.23 20.22 25.59Q20.22 25.95 19.95 26.15Q19.69 26.35 19.22 26.35Q18.75 26.35 18.47 26.14Q18.20 25.93 18.17 25.56ZM20.54 25.05Q20.54 24.65 20.68 24.36Q20.82 24.06 21.08 23.90Q21.35 23.74 21.70 23.74Q22.09 23.74 22.35 23.91Q22.62 24.08 22.74 24.39Q22.87 24.71 22.85 25.12H20.96Q20.96 25.54 21.17 25.78Q21.37 26.01 21.72 26.01Q21.98 26.01 22.16 25.89Q22.33 25.77 22.40 25.55H22.82Q22.75 25.92 22.45 26.13Q22.16 26.34 21.72 26.34Q21.17 26.34 20.85 25.99Q20.54 25.64 20.54 25.05ZM22.42 24.82Q22.40 24.46 22.21 24.27Q22.02 24.07 21.70 24.07Q21.39 24.07 21.19 24.27Q20.99 24.47 20.96 24.82Z"/></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -13261,6 +13261,10 @@
"url-description": "The GitHub repository URL",
"url-required": "Repository URL is required"
},
"github-enterprise": {
"server-url-description": "The custom server URL where your GitHub Enterprise is hosted",
"server-url-label": "Custom server URL"
},
"gitlab": {
"branch-required": "Branch is required",
"permissions": {
@ -13617,6 +13621,9 @@
"bitbucket-tooltip": "Enhanced Bitbucket integration with deep links to source files. Features like webhook-driven sync, PR comments, and repository settings validations are coming soon.",
"github": "GitHub",
"github-description": "Connect to GitHub repositories",
"github-enterprise": "GitHub Enterprise",
"github-enterprise-description": "Connect to GitHub Enterprise Server or GitHub Enterprise Cloud repositories",
"github-enterprise-tooltip": "Enhanced GitHub integration for self-hosted GitHub Enterprise Server or GitHub Enterprise Cloud with webhook-driven sync, PR comments, deep links to source files, and repository settings validations.",
"github-tooltip": "Enhanced GitHub integration with webhook-driven sync, PR comments, deep links to source files, and repository settings validations.",
"gitlab": "GitLab",
"gitlab-description": "Connect to GitLab repositories",
@ -13837,8 +13844,6 @@
"github-app-no-connections": "No GitHub connections found",
"github-app-no-connections-message": "You don't have any existing GitHub app connections. Please select \"Connect to a new app\" to create one.",
"github-app-select-connection": "Select a GitHub App connection",
"github-enterprise-alert-body": "GitHub Enterprise Server is currently only supported through the Pure Git repository type. Native GitHub Enterprise integration is planned and will be available in the upcoming months.",
"github-enterprise-alert-title": "GitHub Enterprise Server",
"give-feedback-link": "Give feedback",
"step-bootstrap": "Choose what to synchronize",
"step-configure-repo": "Configure repository",