mirror of
https://github.com/grafana/grafana.git
synced 2026-07-03 03:37:53 +00:00
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:
parent
24c4289638
commit
144871919b
43 changed files with 664 additions and 240 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export const availableIconsIndex = {
|
|||
google: true,
|
||||
microsoft: true,
|
||||
github: true,
|
||||
'github-enterprise': true,
|
||||
gitlab: true,
|
||||
okta: true,
|
||||
scim: true,
|
||||
|
|
|
|||
|
|
@ -205,5 +205,6 @@
|
|||
"unicons/anthropic-logo",
|
||||
"unicons/cursor-logo",
|
||||
"unicons/github-copilot-logo",
|
||||
"unicons/github-enterprise",
|
||||
"unicons/robot"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {tokenText}</strong>
|
||||
)}
|
||||
<Trans i18nKey="provisioning.token-permissions-info.and-click">and click</Trans>
|
||||
<strong>"{createTokenButtonText}".</strong>
|
||||
<strong>"{createTokenButtonText}".</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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'>;
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
41
public/app/features/provisioning/utils/getFormErrors.test.ts
Normal file
41
public/app/features/provisioning/utils/getFormErrors.test.ts
Normal 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' }]]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
public/img/icons/unicons/github-enterprise.svg
Normal file
1
public/img/icons/unicons/github-enterprise.svg
Normal 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 |
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue