feat(editor): Add GitLab promotion connector setup (no-changelog) (#41019)

This commit is contained in:
Nour Alhadi Mahmoud 2026-10-15 11:58:24 +00:00 committed by GitHub
parent 3c447b42a7
commit 154cd63bbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1626 additions and 54 deletions

View File

@ -3989,6 +3989,23 @@
"settings.promotions.provider.dialog.ariaDescription": "Set the credentials this provider uses to reach Git.",
"settings.promotions.provider.dialog.key.ariaDescription": "Copy the deploy key and add it to your Git provider.",
"settings.promotions.provider.form.name": "Name",
"settings.promotions.provider.form.type": "Provider type",
"settings.promotions.providers.type.git": "Git",
"settings.promotions.providers.type.gitlab": "GitLab",
"settings.promotions.provider.form.baseUrl": "GitLab base URL",
"settings.promotions.provider.form.baseUrl.placeholder": "https://gitlab.example.com",
"settings.promotions.provider.form.accessToken": "Access token",
"settings.promotions.provider.form.accessToken.hint": "Use a personal, group, or project access token with read_api and write_repository scopes",
"settings.promotions.provider.form.accessToken.keepHint": "Leave the token empty to keep the saved token",
"settings.promotions.provider.form.accessToken.urlChangeHint": "Enter an access token for the new GitLab URL",
"settings.promotions.discovery.repository.label": "Repository",
"settings.promotions.discovery.repository.placeholder": "Search or select a repository",
"settings.promotions.discovery.repository.empty": "No repositories found. Try another search or check the token's access.",
"settings.promotions.discovery.repository.selectAgain": "Select a repository again to load its branches",
"settings.promotions.discovery.branch.placeholder": "Search or select a branch",
"settings.promotions.discovery.branch.empty": "No branches found. Try another search or create a branch in GitLab.",
"settings.promotions.discovery.error": "Couldn't load options. Check the provider settings and try again.",
"settings.promotions.discovery.searchHint": "Results for “{search}”",
"settings.promotions.provider.form.authType": "Authentication",
"settings.promotions.provider.form.keyType": "SSH key type",
"settings.promotions.provider.form.username": "Username",

View File

@ -1,5 +1,10 @@
<script setup lang="ts">
import type { PromotionConfigCheckout, PromotionDirection } from '@n8n/api-types';
import {
isPromotionGitHostType,
type PromotionConfigCheckout,
type PromotionDirection,
type PromotionRepository,
} from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import {
N8nButton,
@ -18,7 +23,7 @@ import { useI18n } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUsersStore } from '@n8n/stores/users.store';
import { computed, reactive, ref } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import { usePromotionConnectionSave } from '../composables/usePromotionConnectionSave';
import {
@ -29,6 +34,7 @@ import {
type PromotionProviderSummary,
} from '../promotionsSettings.api';
import PromotionCheckoutStatus from './PromotionCheckoutStatus.vue';
import PromotionGitHostSelect from './PromotionGitHostSelect.vue';
import {
buildConnectionCreatePayload,
connectionFormFrom,
@ -36,6 +42,7 @@ import {
planConnectionWrites,
type ConnectionFormState,
type ConnectionWrite,
type PromotionProviderChange,
} from '../promotionsSettings.utils';
const props = defineProps<{
@ -45,6 +52,7 @@ const props = defineProps<{
const emit = defineEmits<{
saved: [connection: PromotionConnection];
refresh: [];
'add-provider': [];
}>();
@ -66,6 +74,71 @@ const form = reactive<ConnectionFormState>(
);
const baseline = ref(JSON.stringify(form));
const isCreating = ref(false);
const discoveryRevision = ref(0);
let savedStateRevision = 0;
const selectedProvider = computed(
() =>
props.providers.find(({ id }) => id === form.providerId) ??
(current.value?.provider.id === form.providerId ? current.value.provider : undefined),
);
const hasHostApi = computed(
() => selectedProvider.value && isPromotionGitHostType(selectedProvider.value.type),
);
watch(
() => props.connection,
(connection) => {
savedStateRevision++;
// Refresh saved state without replacing the draft.
current.value = connection;
// A provider host change keeps the path but changes the derived URL.
if (
form.repositoryPath &&
form.providerId === connection?.provider.id &&
form.repositoryPath === connection?.target.repositoryPath
) {
form.remoteUrl = connection.target.remoteUrl;
}
form.apply.name = connection?.configs.apply?.name;
form.promote.name = connection?.configs.promote?.name;
baseline.value = JSON.stringify(
connection ? connectionFormFrom(connection) : emptyConnectionForm(),
);
},
);
function applyProviderChange(change: PromotionProviderChange) {
if (
(change.hostChanged || change.credentialsChanged) &&
(change.id === form.providerId || change.id === current.value?.provider.id)
) {
// Pending writes can refer to either provider during reassignment.
savedStateRevision++;
}
if (change.id !== form.providerId) return;
// The new host rejects a URL picked from the old host, unless it is the saved repository.
if (
change.hostChanged &&
(form.providerId !== current.value?.provider.id ||
form.repositoryPath !== current.value?.target.repositoryPath)
) {
form.remoteUrl = '';
form.repositoryPath = undefined;
}
if (change.hostChanged || change.credentialsChanged) discoveryRevision.value++;
}
function selectRepository(selected: PromotionRepository) {
if (
form.remoteUrl !== selected.remoteUrl ||
(form.repositoryPath !== undefined && form.repositoryPath !== selected.fullPath)
) {
form.apply.branchName = '';
form.promote.baseBranchName = '';
}
form.remoteUrl = selected.remoteUrl;
form.repositoryPath = selected.fullPath;
}
const resetTo = (connection: PromotionConnection | null) => {
current.value = connection;
@ -76,10 +149,18 @@ const resetTo = (connection: PromotionConnection | null) => {
const isSaving = computed(() => isCreating.value || save.isSaving.value);
const isDirty = computed(() => JSON.stringify(form) !== baseline.value);
const needsRepositorySelection = computed(
() => hasHostApi.value && !!form.remoteUrl && !form.repositoryPath,
);
// Let the API validate remote URLs so frontend rules cannot drift.
const saveDisabledReason = computed(() => {
if (!form.providerId || !form.name.trim() || !form.remoteUrl.trim())
if (
!form.providerId ||
!form.name.trim() ||
!form.remoteUrl.trim() ||
(hasHostApi.value && !form.repositoryPath)
)
return i18n.baseText('settings.promotions.connection.form.incomplete');
if (
(form.apply.enabled && !form.apply.branchName.trim()) ||
@ -107,6 +188,8 @@ async function submit() {
if (saveDisabledReason.value !== undefined || isSaving.value) return;
const existing = current.value;
const revision = discoveryRevision.value;
const savedRevision = savedStateRevision;
if (!existing) {
// Creation saves both directions in one request.
@ -116,11 +199,15 @@ async function submit() {
rootStore.publicApiContext,
buildConnectionCreatePayload(form),
);
resetTo(created);
toast.showMessage({
title: i18n.baseText('settings.promotions.connection.toast.created'),
type: 'success',
});
if (revision !== discoveryRevision.value || savedRevision !== savedStateRevision) {
emit('refresh');
return;
}
resetTo(created);
emit('saved', created);
} catch (error) {
toast.showError(error, i18n.baseText('settings.promotions.connection.toast.error.save'));
@ -132,23 +219,31 @@ async function submit() {
// Build a new plan so retries include only unsaved changes.
const result = await save.run(existing, planConnectionWrites(form, existing));
current.value = result.connection;
emit('saved', result.connection);
if (result.failed.length > 0) {
toast.showError(result.error, i18n.baseText('settings.promotions.connection.toast.error.save'));
} else {
toast.showMessage({
title: i18n.baseText('settings.promotions.connection.toast.updated'),
type: 'success',
});
}
if (revision !== discoveryRevision.value || savedRevision !== savedStateRevision) {
// Read saved state again after a provider edit or a concurrent refresh.
save.reset();
emit('refresh');
return;
}
current.value = result.connection;
form.apply.name = result.connection.configs.apply?.name;
form.promote.name = result.connection.configs.promote?.name;
emit('saved', result.connection);
baseline.value = JSON.stringify(form);
toast.showMessage({
title: i18n.baseText('settings.promotions.connection.toast.updated'),
type: 'success',
});
if (result.failed.length === 0) resetTo(result.connection);
}
function discard() {
resetTo(current.value);
discoveryRevision.value++;
}
// A clone reads the saved config, so Connect stays blocked until the direction is
@ -173,6 +268,8 @@ const connectDisabledReason = (direction: PromotionDirection): string | undefine
if (!canClone.value) return i18n.baseText('settings.promotions.connection.checkout.noPermission');
if (!current.value?.configs[direction])
return i18n.baseText('settings.promotions.connection.checkout.saveFirst');
if (needsRepositorySelection.value)
return i18n.baseText('settings.promotions.discovery.repository.selectAgain');
if (isDirty.value)
return i18n.baseText('settings.promotions.connection.checkout.saveChangesFirst');
return undefined;
@ -194,6 +291,7 @@ function applyCheckout(direction: PromotionDirection, checkout: PromotionConfigC
async function connect(direction: PromotionDirection) {
if (!current.value || connectDisabledReason(direction) !== undefined) return;
const revision = savedStateRevision;
connecting[direction] = 'connect';
try {
const result = await clonePromotionCheckout(
@ -201,6 +299,10 @@ async function connect(direction: PromotionDirection) {
current.value.id,
direction,
);
if (revision !== savedStateRevision) {
emit('refresh');
return;
}
applyCheckout(direction, {
hasCheckout: result.hasCheckout,
matchesConfig: result.hasCheckout,
@ -221,9 +323,14 @@ async function connect(direction: PromotionDirection) {
async function disconnect(direction: PromotionDirection) {
if (!current.value) return;
const revision = savedStateRevision;
connecting[direction] = 'disconnect';
try {
await disconnectPromotionCheckout(rootStore.publicApiContext, current.value.id, direction);
if (revision !== savedStateRevision) {
emit('refresh');
return;
}
applyCheckout(direction, { hasCheckout: false, matchesConfig: false });
toast.showMessage({
title: i18n.baseText('settings.promotions.connection.checkout.toast.disconnected'),
@ -240,10 +347,22 @@ async function disconnect(direction: PromotionDirection) {
}
function selectProvider(id: string) {
if (id === form.providerId) return;
const previous = selectedProvider.value;
const next = props.providers.find((provider) => provider.id === id);
if (
(previous && isPromotionGitHostType(previous.type)) ||
(next && isPromotionGitHostType(next.type))
) {
form.remoteUrl = '';
form.apply.branchName = '';
form.promote.baseBranchName = '';
}
form.repositoryPath = undefined;
form.providerId = id;
}
defineExpose({ selectProvider });
defineExpose({ selectProvider, applyProviderChange });
</script>
<template>
@ -256,10 +375,11 @@ defineExpose({ selectProvider });
<div :class="$style.providerRow">
<N8nSelect
id="promotion-connection-provider"
v-model="form.providerId"
:model-value="form.providerId"
:disabled="isSaving"
:placeholder="i18n.baseText('settings.promotions.connection.form.provider.placeholder')"
data-test-id="promotion-connection-provider-select"
@update:model-value="selectProvider"
>
<N8nOption
v-for="provider in providers"
@ -294,6 +414,36 @@ defineExpose({ selectProvider });
</N8nInputLabel>
<N8nInputLabel
v-if="hasHostApi"
input-name="promotion-connection-repository"
:label="i18n.baseText('settings.promotions.discovery.repository.label')"
required
>
<PromotionGitHostSelect
:model-value="form.remoteUrl"
kind="repository"
input-id="promotion-connection-repository"
:provider-id="form.providerId"
:revision="discoveryRevision"
:description-id="
needsRepositorySelection ? 'promotion-repository-selection-hint' : undefined
"
:disabled="isSaving"
data-test-id="promotion-connection-repository-select"
@selected="selectRepository"
/>
<N8nText
v-if="needsRepositorySelection"
id="promotion-repository-selection-hint"
size="small"
color="text-light"
role="status"
>
{{ i18n.baseText('settings.promotions.discovery.repository.selectAgain') }}
</N8nText>
</N8nInputLabel>
<N8nInputLabel
v-else
input-name="promotion-connection-remote-url"
:label="i18n.baseText('settings.promotions.connection.form.remoteUrl')"
required
@ -332,11 +482,23 @@ defineExpose({ selectProvider });
required
>
<N8nInput
v-if="!hasHostApi"
id="promotion-connection-apply-branch"
v-model="form.apply.branchName"
:disabled="isSaving"
data-test-id="promotion-connection-apply-branch-input"
/>
<PromotionGitHostSelect
v-else
v-model="form.apply.branchName"
kind="branch"
input-id="promotion-connection-apply-branch"
:provider-id="form.providerId"
:repository-id="form.repositoryPath"
:revision="discoveryRevision"
:disabled="isSaving"
data-test-id="promotion-connection-apply-branch-select"
/>
</N8nInputLabel>
<PromotionCheckoutStatus
:checkout="savedCheckout('apply')"
@ -373,11 +535,23 @@ defineExpose({ selectProvider });
required
>
<N8nInput
v-if="!hasHostApi"
id="promotion-connection-promote-branch"
v-model="form.promote.baseBranchName"
:disabled="isSaving"
data-test-id="promotion-connection-promote-branch-input"
/>
<PromotionGitHostSelect
v-else
v-model="form.promote.baseBranchName"
kind="branch"
input-id="promotion-connection-promote-branch"
:provider-id="form.providerId"
:repository-id="form.repositoryPath"
:revision="discoveryRevision"
:disabled="isSaving"
data-test-id="promotion-connection-promote-branch-select"
/>
</N8nInputLabel>
<N8nSwitch
v-model="form.promote.createBranchOnPromotion"

View File

@ -0,0 +1,190 @@
import type { PromotionRepository, PromotionRepositoryListPublicDto } from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import { flushPromises } from '@vue/test-utils';
import { screen, waitFor, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import type * as PromotionsApi from '../promotionsSettings.api';
import PromotionGitHostSelect from './PromotionGitHostSelect.vue';
const api = vi.hoisted(() => ({
fetchPromotionRepositories: vi.fn<typeof PromotionsApi.fetchPromotionRepositories>(),
fetchPromotionBranches: vi.fn<typeof PromotionsApi.fetchPromotionBranches>(),
}));
vi.mock('../promotionsSettings.api', () => api);
const repository: PromotionRepository = {
id: '42',
name: 'Workflows',
fullPath: 'team/workflows',
remoteUrl: 'https://gitlab.example.com/team/workflows.git',
defaultBranch: 'main',
};
const otherRepository: PromotionRepository = {
...repository,
id: '43',
name: 'Other',
fullPath: 'team/other',
remoteUrl: 'https://gitlab.example.com/team/other.git',
};
const renderSelect = createComponentRenderer(PromotionGitHostSelect, {
props: { modelValue: '', providerId: 'gitlab-1', kind: 'repository', inputId: 'repository' },
});
beforeEach(() => {
vi.resetAllMocks();
createTestingPinia();
api.fetchPromotionRepositories.mockResolvedValue({ data: [repository], nextCursor: null });
api.fetchPromotionBranches.mockResolvedValue({
data: [{ name: 'main', isDefault: true }],
nextCursor: null,
});
});
it('ignores an old provider response while the current provider is still loading', async () => {
let resolveOld!: (page: PromotionRepositoryListPublicDto) => void;
let resolveCurrent!: (page: PromotionRepositoryListPublicDto) => void;
api.fetchPromotionRepositories
.mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveCurrent = resolve;
}),
);
const { rerender } = renderSelect();
await rerender({ providerId: 'gitlab-2' });
await waitFor(() => expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(2));
resolveOld({ data: [repository], nextCursor: 'stale-page' });
await flushPromises();
await userEvent.click(screen.getByRole('combobox'));
expect(await screen.findByText('Loading')).toBeVisible();
expect(screen.queryByText('team/workflows')).not.toBeInTheDocument();
resolveCurrent({ data: [otherRepository], nextCursor: null });
expect(await screen.findByText('team/other')).toBeInTheDocument();
expect(screen.queryByText('team/workflows')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
});
it('keeps a saved selection outside discovery results and clears empty-search feedback on reselection', async () => {
api.fetchPromotionRepositories.mockResolvedValue({ data: [otherRepository], nextCursor: 'next' });
const { emitted } = renderSelect({ props: { modelValue: repository.remoteUrl } });
await flushPromises();
expect(screen.getByRole('combobox')).toHaveValue(repository.remoteUrl);
expect(api.fetchPromotionRepositories).toHaveBeenCalledExactlyOnceWith(
expect.anything(),
'gitlab-1',
{ search: undefined, limit: 25 },
);
expect(emitted('selected')).toBeUndefined();
expect(emitted('update:modelValue')).toBeUndefined();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
api.fetchPromotionRepositories.mockResolvedValue({ data: [], nextCursor: null });
await userEvent.type(screen.getByRole('combobox'), 'team');
await screen.findByText(/No repositories found/, { selector: '[role="status"]' });
await userEvent.click(screen.getByRole('option', { name: repository.remoteUrl }));
await waitFor(() => expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(3));
expect(screen.getByRole('combobox')).toHaveValue(repository.remoteUrl);
expect(screen.queryByText('Results for “team”')).not.toBeInTheDocument();
expect(screen.queryByText(/No repositories found/)).not.toBeInTheDocument();
});
describe.each(['repository', 'branch'] as const)('filtered %s discovery', (kind) => {
const requests = () =>
(kind === 'repository'
? api.fetchPromotionRepositories.mock.calls
: api.fetchPromotionBranches.mock.calls
).map((args) => args.at(-1));
const renderPicker = (onClick: () => void) =>
renderSelect({
props: { kind, repositoryId: kind === 'branch' ? 'team/workflows' : undefined },
attrs: { onClick },
});
it('loads up to 25 matches and keeps search and selection through a retry', async () => {
api.fetchPromotionRepositories
.mockResolvedValueOnce({ data: [repository], nextCursor: 'unfiltered-next' })
.mockRejectedValueOnce(new Error('GitLab is unavailable.'))
.mockResolvedValueOnce({
data: [repository, otherRepository],
nextCursor: 'filtered-next',
});
api.fetchPromotionBranches
.mockResolvedValueOnce({
data: [{ name: 'main', isDefault: true }],
nextCursor: 'unfiltered-next',
})
.mockRejectedValueOnce(new Error('GitLab is unavailable.'))
.mockResolvedValueOnce({
data: [
{ name: 'team/main', isDefault: false },
{ name: 'team/other', isDefault: false },
],
nextCursor: 'filtered-next',
});
const onClick = vi.fn();
const { emitted } = renderPicker(onClick);
await waitFor(() => expect(requests()).toHaveLength(1));
const input = screen.getByRole('combobox');
expect(input).toHaveAttribute(
'placeholder',
kind === 'repository' ? 'Search or select a repository' : 'Search or select a branch',
);
expect(requests()[0]).toEqual({ search: undefined, limit: 25 });
await userEvent.type(input, 'team');
await waitFor(() => expect(requests()).toHaveLength(2));
onClick.mockClear();
await userEvent.click(await screen.findByRole('button', { name: 'Retry' }));
await waitFor(() => expect(requests()).toHaveLength(3));
expect(requests()[1]).toEqual({ search: 'team', limit: 25 });
expect(requests()[2]).toEqual(requests()[1]);
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
expect(onClick).not.toHaveBeenCalled();
expect(screen.getByRole('combobox')).toHaveValue('');
expect(screen.getByText('Results for “team”')).toBeVisible();
await userEvent.click(screen.getByRole('combobox'));
expect(requests()).toHaveLength(3);
expect(
screen.getAllByRole('option', {
name: kind === 'repository' ? 'team/workflows' : 'team/main',
}),
).toHaveLength(1);
await userEvent.click(await screen.findByRole('option', { name: 'team/other' }));
expect(emitted('update:modelValue')).toEqual([
[kind === 'repository' ? otherRepository.remoteUrl : 'team/other'],
]);
expect(emitted('selected')).toEqual(kind === 'repository' ? [[otherRepository]] : undefined);
expect(screen.queryByText('Results for “team”')).not.toBeInTheDocument();
});
});
it('uses native Tab order to leave the picker without a pagination action', async () => {
api.fetchPromotionRepositories.mockResolvedValue({ data: [repository], nextCursor: 'next' });
const { container } = renderSelect();
container.insertAdjacentHTML('beforeend', '<button type="button">After picker</button>');
await flushPromises();
await userEvent.click(screen.getByRole('combobox'));
await userEvent.tab();
expect(screen.getByRole('button', { name: 'After picker' })).toHaveFocus();
expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(1);
});
it('shows one retry action for an initial failure and then an empty result', async () => {
api.fetchPromotionRepositories
.mockRejectedValueOnce(new Error('GitLab is unavailable.'))
.mockResolvedValueOnce({ data: [], nextCursor: null });
renderSelect();
await screen.findByRole('alert');
await userEvent.click(screen.getByRole('combobox'));
expect(screen.getAllByRole('button', { name: 'Retry' })).toHaveLength(1);
await userEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }));
await waitFor(() => expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
expect(await screen.findByRole('status')).toHaveTextContent('No repositories found');
});

View File

@ -0,0 +1,137 @@
<script setup lang="ts">
import type { PromotionRepository } from '@n8n/api-types';
import { N8nButton, N8nNotice, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed, ref, watch } from 'vue';
import { usePromotionGitHostDiscovery } from '../composables/usePromotionGitHostDiscovery';
const props = defineProps<{
modelValue: string;
providerId: string;
kind: 'repository' | 'branch';
repositoryId?: string;
revision?: number;
inputId: string;
descriptionId?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
selected: [repository: PromotionRepository];
}>();
const i18n = useI18n();
const { options, search, loading, error, onSearch, cancel, retry } =
usePromotionGitHostDiscovery(props);
const selectedRepository = ref<PromotionRepository>();
watch(
() => [props.providerId, props.revision],
() => {
selectedRepository.value = undefined;
},
);
const unavailable = computed(
() => props.disabled || (props.kind === 'branch' && !props.repositoryId),
);
const emptyMessage = computed(() =>
i18n.baseText(
props.kind === 'repository'
? 'settings.promotions.discovery.repository.empty'
: 'settings.promotions.discovery.branch.empty',
),
);
const visibleOptions = computed(() => {
if (!props.modelValue || options.value.some(({ value }) => value === props.modelValue))
return options.value;
const selected =
selectedRepository.value?.remoteUrl === props.modelValue
? {
value: props.modelValue,
label: selectedRepository.value.fullPath,
repository: selectedRepository.value,
}
: { value: props.modelValue, label: props.modelValue };
return [selected, ...options.value];
});
function select(value: string) {
const option = visibleOptions.value.find((item) => item.value === value);
cancel();
onSearch('');
emit('update:modelValue', value);
if (option?.repository) {
selectedRepository.value = option.repository;
emit('selected', option.repository);
}
}
function onInput(event: Event) {
// Dropdown close events must not clear the search used by retry.
if (event.target instanceof HTMLInputElement) onSearch(event.target.value);
}
</script>
<template>
<div :class="$style.picker" @input="onInput">
<N8nSelect
:id="inputId"
:aria-describedby="descriptionId"
:model-value="modelValue"
:disabled="unavailable"
:loading="loading"
:teleported="false"
filterable
remote
:placeholder="
i18n.baseText(
kind === 'repository'
? 'settings.promotions.discovery.repository.placeholder'
: 'settings.promotions.discovery.branch.placeholder',
)
"
:no-data-text="emptyMessage"
:no-match-text="emptyMessage"
@update:model-value="select"
>
<N8nOption
v-for="option in visibleOptions"
:key="`${option.value}:${option.label}`"
:value="option.value"
:label="option.label"
/>
</N8nSelect>
<N8nText v-if="search" size="small" color="text-light" role="status">
{{ i18n.baseText('settings.promotions.discovery.searchHint', { interpolate: { search } }) }}
</N8nText>
<N8nText
v-if="!loading && !error && !unavailable && options.length === 0 && (!modelValue || search)"
size="small"
color="text-light"
role="status"
>
{{ emptyMessage }}
</N8nText>
<N8nNotice v-if="error" theme="danger" role="alert">
{{ error }}
<N8nButton
type="button"
variant="outline"
:disabled="unavailable || loading"
@click.stop="retry"
>
{{ i18n.baseText('generic.retry') }}
</N8nButton>
</N8nNotice>
</div>
</template>
<style lang="scss" module>
.picker {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
</style>

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { promotionProviderTypeCapabilities, supportsPromotionAuthType } from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import {
N8nButton,
@ -18,7 +19,7 @@ import {
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, nextTick, onMounted, reactive, ref, useTemplateRef } from 'vue';
import { computed, nextTick, onMounted, reactive, ref, useTemplateRef, watch } from 'vue';
import { MODAL_CONFIRM } from '@/app/constants';
import {
@ -34,7 +35,9 @@ import {
buildProviderCreatePayload,
buildProviderUpdatePayload,
emptyProviderForm,
isSamePromotionHostUrl,
providerFormFrom,
type PromotionProviderChange,
type ProviderFormState,
} from '../promotionsSettings.utils';
@ -45,7 +48,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:open': [value: boolean];
saved: [id: string];
saved: [change: PromotionProviderChange];
deleted: [id: string];
}>();
@ -62,10 +65,31 @@ const usedBy = ref<PromotionConnectionSummary[]>([]);
const generatedPublicKey = ref<string | null>(null);
const isLoading = ref(false);
const isSubmitting = ref(false);
const saveError = ref<string>();
const nameInput = useTemplateRef<InstanceType<typeof N8nInput>>('nameInput');
const doneButton = useTemplateRef<{ $el?: HTMLElement }>('doneButton');
const isEdit = computed(() => props.providerId !== undefined);
const capabilities = computed(() => promotionProviderTypeCapabilities[form.type]);
watch(
() => form.type,
() => {
if (!supportsPromotionAuthType(form.type, form.authType)) {
form.authType = capabilities.value.authTypes[0];
}
form.username = '';
form.password = '';
saveError.value = undefined;
},
);
watch(
() => [form.baseUrl, form.password],
() => {
saveError.value = undefined;
},
);
const title = computed(() => {
if (step.value === 'key') return i18n.baseText('settings.promotions.provider.dialog.title.key');
@ -95,10 +119,18 @@ const usedByNames = computed(() => usedBy.value.map((connection) => connection.n
const hasUsername = computed(() => form.username.trim().length > 0);
const hasPassword = computed(() => form.password.length > 0);
const hasHostUrlChange = computed(() => {
const config = current.value?.config;
return config && 'baseUrl' in config
? !isSamePromotionHostUrl(config.baseUrl, form.baseUrl)
: false;
});
// Token credentials change as a pair. Empty edit fields keep the stored credentials.
const areCredentialsIncomplete = computed(() => {
if (form.authType !== 'token') return false;
if (capabilities.value.tokenUsername)
return (!isEdit.value || hasHostUrlChange.value) && !hasPassword.value;
if (!isEdit.value) return !(hasUsername.value && hasPassword.value);
if (!hasUsername.value && !hasPassword.value) return false;
return !(hasUsername.value && hasPassword.value);
@ -111,6 +143,8 @@ const hasChanges = computed(() => {
const saveDisabledReason = computed(() => {
if (!form.name.trim()) return i18n.baseText('settings.promotions.provider.form.incomplete');
if (capabilities.value.hasHostApi && !form.baseUrl.trim())
return i18n.baseText('settings.promotions.provider.form.incomplete');
if (areCredentialsIncomplete.value)
return i18n.baseText('settings.promotions.provider.form.credentials.required');
if (!hasChanges.value) return i18n.baseText('settings.promotions.provider.form.noChanges');
@ -177,12 +211,14 @@ async function submit() {
const existing = current.value;
isSubmitting.value = true;
saveError.value = undefined;
try {
if (existing) {
const payload = buildProviderUpdatePayload(form, existing);
const updated = await updatePromotionProvider(
rootStore.publicApiContext,
existing.id,
buildProviderUpdatePayload(form, existing),
payload,
);
const rotatedKey =
'publicKey' in updated.config && updated.config.publicKey !== storedPublicKey.value
@ -193,7 +229,11 @@ async function submit() {
title: i18n.baseText('settings.promotions.provider.toast.updated'),
type: 'success',
});
emit('saved', updated.id);
emit('saved', {
id: updated.id,
hostChanged: payload.config !== undefined,
credentialsChanged: payload.auth !== undefined,
});
if (rotatedKey) {
generatedPublicKey.value = rotatedKey;
@ -211,7 +251,7 @@ async function submit() {
title: i18n.baseText('settings.promotions.provider.toast.created'),
type: 'success',
});
emit('saved', created.provider.id);
emit('saved', { id: created.provider.id, hostChanged: false, credentialsChanged: false });
if (created.publicKey) {
generatedPublicKey.value = created.publicKey;
@ -222,7 +262,10 @@ async function submit() {
}
}
} catch (error) {
toast.showError(error, i18n.baseText('settings.promotions.provider.toast.error.save'));
saveError.value =
error instanceof Error
? error.message
: i18n.baseText('settings.promotions.provider.toast.error.save');
} finally {
isSubmitting.value = false;
}
@ -322,12 +365,47 @@ async function onDelete() {
id="promotion-provider-name"
ref="nameInput"
v-model="form.name"
:disabled="isLoading"
:disabled="isLoading || isSubmitting"
data-test-id="promotion-provider-name-input"
/>
</N8nInputLabel>
<N8nInputLabel
input-name="promotion-provider-type"
:label="i18n.baseText('settings.promotions.provider.form.type')"
>
<N8nSelect
id="promotion-provider-type"
v-model="form.type"
:teleported="false"
:disabled="isLoading || isSubmitting || isEdit"
data-test-id="promotion-provider-type-select"
>
<N8nOption value="git" :label="i18n.baseText('settings.promotions.providers.type.git')" />
<N8nOption
value="gitlab"
:label="i18n.baseText('settings.promotions.providers.type.gitlab')"
/>
</N8nSelect>
</N8nInputLabel>
<N8nInputLabel
v-if="capabilities.hasHostApi"
input-name="promotion-provider-base-url"
:label="i18n.baseText('settings.promotions.provider.form.baseUrl')"
required
>
<N8nInput
id="promotion-provider-base-url"
v-model="form.baseUrl"
:placeholder="i18n.baseText('settings.promotions.provider.form.baseUrl.placeholder')"
:disabled="isLoading || isSubmitting"
data-test-id="promotion-provider-base-url-input"
/>
</N8nInputLabel>
<N8nInputLabel
v-if="capabilities.authTypes.length > 1"
input-name="promotion-provider-auth-type"
:label="i18n.baseText('settings.promotions.provider.form.authType')"
:tooltip-text="
@ -338,7 +416,7 @@ async function onDelete() {
id="promotion-provider-auth-type"
v-model="form.authType"
:teleported="false"
:disabled="isLoading || isEdit"
:disabled="isLoading || isSubmitting || isEdit"
data-test-id="promotion-provider-auth-type-select"
>
<N8nOption
@ -362,7 +440,7 @@ async function onDelete() {
id="promotion-provider-key-type"
v-model="form.keyType"
:teleported="false"
:disabled="isLoading"
:disabled="isLoading || isSubmitting"
data-test-id="promotion-provider-key-type-select"
>
<N8nOption value="ed25519" label="ED25519" />
@ -386,7 +464,7 @@ async function onDelete() {
<N8nCheckbox
v-model="form.regenerateKey"
:label="i18n.baseText('settings.promotions.provider.regenerateKey')"
:disabled="isLoading"
:disabled="isLoading || isSubmitting"
data-test-id="promotion-provider-regenerate-key"
/>
<N8nText v-if="form.regenerateKey" size="small" color="text-light">
@ -397,6 +475,7 @@ async function onDelete() {
<template v-else>
<N8nInputLabel
v-if="!capabilities.tokenUsername"
input-name="promotion-provider-username"
:label="i18n.baseText('settings.promotions.provider.form.username')"
:required="!isEdit"
@ -404,35 +483,69 @@ async function onDelete() {
<N8nInput
id="promotion-provider-username"
v-model="form.username"
:disabled="isLoading"
:disabled="isLoading || isSubmitting"
data-test-id="promotion-provider-username-input"
/>
</N8nInputLabel>
<N8nInputLabel
input-name="promotion-provider-password"
:label="i18n.baseText('settings.promotions.provider.form.password')"
:required="!isEdit"
:label="
i18n.baseText(
capabilities.hasHostApi
? 'settings.promotions.provider.form.accessToken'
: 'settings.promotions.provider.form.password',
)
"
:required="!isEdit || hasHostUrlChange"
>
<N8nInput
id="promotion-provider-password"
v-model="form.password"
type="password"
autocomplete="new-password"
:disabled="isLoading"
:disabled="isLoading || isSubmitting"
data-test-id="promotion-provider-password-input"
/>
</N8nInputLabel>
<N8nText v-if="hasUsername !== hasPassword" size="small" color="danger">
<N8nText
v-if="!capabilities.tokenUsername && hasUsername !== hasPassword"
size="small"
color="danger"
>
{{ i18n.baseText('settings.promotions.provider.form.credentials.required') }}
</N8nText>
<N8nText v-if="isEdit" size="small" color="text-light">
{{ i18n.baseText('settings.promotions.provider.form.credentials.keepHint') }}
<N8nText
v-if="isEdit"
size="small"
:color="capabilities.hasHostApi ? 'text-base' : 'text-light'"
>
{{
i18n.baseText(
capabilities.hasHostApi
? hasHostUrlChange
? 'settings.promotions.provider.form.accessToken.urlChangeHint'
: 'settings.promotions.provider.form.accessToken.keepHint'
: 'settings.promotions.provider.form.credentials.keepHint',
)
}}
</N8nText>
<N8nText v-if="capabilities.hasHostApi" size="small" color="text-base">
{{ i18n.baseText('settings.promotions.provider.form.accessToken.hint') }}
</N8nText>
</template>
<N8nNotice
v-if="saveError"
theme="danger"
role="alert"
data-test-id="promotion-provider-save-error"
>
{{ saveError }}
</N8nNotice>
<N8nDialogFooter>
<N8nButton
v-if="isEdit"

View File

@ -18,7 +18,7 @@ export type ConnectionSaveResult = {
error?: unknown;
};
/** Tries every write and returns the successful state and failed writes. */
/** Saves independent writes. Target changes must succeed before direction settings. */
export function usePromotionConnectionSave() {
const rootStore = useRootStore();
@ -69,15 +69,26 @@ export function usePromotionConnectionSave() {
let connection = current;
const failed: ConnectionWrite[] = [];
let error: unknown;
let targetFailed = false;
isSaving.value = true;
try {
for (const write of writes) {
if (targetFailed && write.kind !== 'connection') {
failed.push(write);
continue;
}
try {
connection = await runWrite(current.id, write, connection);
} catch (writeError) {
failed.push(write);
error ??= writeError;
if (
write.kind === 'connection' &&
(write.payload.target !== undefined || write.payload.providerId !== undefined)
) {
targetFailed = true;
}
}
}
} finally {

View File

@ -0,0 +1,45 @@
import { createTestingPinia } from '@pinia/testing';
import { flushPromises } from '@vue/test-utils';
import { effectScope, reactive, nextTick } from 'vue';
import { fetchPromotionBranches, fetchPromotionRepositories } from '../promotionsSettings.api';
import { usePromotionGitHostDiscovery } from './usePromotionGitHostDiscovery';
vi.mock('../promotionsSettings.api', () => ({
fetchPromotionRepositories: vi.fn(),
fetchPromotionBranches: vi.fn(),
}));
beforeEach(() => {
vi.resetAllMocks();
createTestingPinia();
vi.mocked(fetchPromotionBranches).mockResolvedValue({
data: [{ name: 'main', isDefault: true }],
nextCursor: null,
});
});
// The disabled UI cannot exercise a search call without a selected repository.
it('ignores branch searches until a repository is selected', async () => {
const context = reactive<{ providerId: string; kind: 'branch'; repositoryId?: string }>({
providerId: 'gitlab-1',
kind: 'branch',
});
const scope = effectScope();
try {
const discovery = scope.run(() => usePromotionGitHostDiscovery(context));
if (!discovery) throw new Error('The discovery scope did not start');
discovery.onSearch('main');
expect(discovery.loading.value).toBe(false);
expect(discovery.search.value).toBe('');
expect(fetchPromotionBranches).not.toHaveBeenCalled();
expect(fetchPromotionRepositories).not.toHaveBeenCalled();
context.repositoryId = '42';
await nextTick();
await flushPromises();
expect(discovery.options.value).toEqual([{ value: 'main', label: 'main' }]);
expect(discovery.loading.value).toBe(false);
} finally {
scope.stop();
}
});

View File

@ -0,0 +1,113 @@
import type { PromotionRepository } from '@n8n/api-types';
import { getDebounceTime } from '@n8n/composables/useDebounce';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useDebounceFn } from '@vueuse/core';
import { onScopeDispose, ref, watch } from 'vue';
import { DEBOUNCE_TIME } from '@/app/constants';
import { fetchPromotionBranches, fetchPromotionRepositories } from '../promotionsSettings.api';
type DiscoveryContext = {
providerId: string;
kind: 'repository' | 'branch';
repositoryId?: string;
revision?: number;
};
type DiscoveryOption = { value: string; label: string; repository?: PromotionRepository };
/** Loads one searchable list. */
export function usePromotionGitHostDiscovery(context: DiscoveryContext) {
const rootStore = useRootStore();
const i18n = useI18n();
const options = ref<DiscoveryOption[]>([]);
const search = ref('');
const loading = ref(false);
const error = ref<string>();
let generation = 0;
async function load(requestGeneration: number) {
const repositoryId = context.repositoryId;
if (requestGeneration !== generation || (context.kind === 'branch' && !repositoryId)) return;
loading.value = true;
error.value = undefined;
try {
const query = { search: search.value || undefined, limit: 25 };
let pageOptions: DiscoveryOption[];
if (context.kind === 'repository') {
const page = await fetchPromotionRepositories(
rootStore.publicApiContext,
context.providerId,
query,
);
pageOptions = page.data.map((repository) => ({
value: repository.remoteUrl,
label: repository.fullPath,
repository,
}));
} else if (repositoryId) {
const page = await fetchPromotionBranches(
rootStore.publicApiContext,
context.providerId,
repositoryId,
query,
);
pageOptions = page.data.map((branch) => ({ value: branch.name, label: branch.name }));
} else return;
if (requestGeneration !== generation) return;
options.value = pageOptions;
} catch (cause) {
if (requestGeneration !== generation) return;
error.value =
cause instanceof Error
? cause.message
: i18n.baseText('settings.promotions.discovery.error');
} finally {
if (requestGeneration === generation) loading.value = false;
}
}
const debouncedLoad = useDebounceFn(load, getDebounceTime(DEBOUNCE_TIME.INPUT.SEARCH));
function onSearch(value: string) {
if (context.kind === 'branch' && !context.repositoryId) return;
const query = value.trim();
if (query === search.value) return;
search.value = query;
options.value = [];
error.value = undefined;
loading.value = true;
void debouncedLoad(++generation);
}
function cancel() {
generation++;
loading.value = false;
error.value = undefined;
}
watch(
() => [context.providerId, context.repositoryId, context.kind, context.revision],
() => {
cancel();
search.value = '';
options.value = [];
void load(generation);
},
{ immediate: true },
);
onScopeDispose(() => {
generation++;
});
return {
options,
search,
loading,
error,
onSearch,
cancel,
retry: async () => await load(generation),
};
}

View File

@ -3,11 +3,43 @@ import {
applyProjectSelection,
continueApplyProjectSelection,
continueApplyPromotion,
fetchPromotionRepositories,
fetchPromotionBranches,
} from './promotionsSettings.api';
import { blocked } from './__tests__/bindings.fixtures';
vi.mock('@n8n/rest-api-client', () => ({ request: vi.fn() }));
it('requests repositories with the encoded provider, query, and public API base URL', async () => {
const result = { data: [], nextCursor: 'next' };
const context = { baseUrl: '/custom/api/v2' };
const query = { search: 'team', cursor: 'page', limit: 50 };
vi.mocked(request).mockResolvedValue(result);
expect(await fetchPromotionRepositories(context, 'provider/one', query)).toBe(result);
expect(request).toHaveBeenLastCalledWith({
method: 'GET',
baseURL: context.baseUrl,
endpoint: '/promotions/providers/provider%2Fone/repositories',
data: query,
});
});
it('requests branches with the encoded repository path and returns the complete page', async () => {
const result = { data: [], nextCursor: 'next' };
const context = { baseUrl: '/custom/api/v2' };
const query = { search: 'release/', cursor: 'page', limit: 50 };
vi.mocked(request).mockResolvedValue(result);
expect(await fetchPromotionBranches(context, 'provider/one', 'team/workflows', query)).toBe(
result,
);
expect(request).toHaveBeenLastCalledWith({
method: 'GET',
baseURL: context.baseUrl,
endpoint: '/promotions/providers/provider%2Fone/repositories/team%2Fworkflows/branches',
data: query,
});
});
it('uses the public API context and returns the complete result', async () => {
const result = blocked();
const context = { baseUrl: '/custom/api/v2' };

View File

@ -6,6 +6,8 @@ import type {
ContinueApplySelectionDto,
CreatePromotionConnectionDto,
CreatePromotionProviderDto,
ListPromotionRepositoriesQueryDto,
ListPromotionBranchesQueryDto,
PromotionApplyConfigPublicDto,
PromotionCheckoutPublicDto,
PromotionConnectionPublicDto,
@ -17,6 +19,8 @@ import type {
PromotionProviderCreatedPublicDto,
PromotionProviderListPublicDto,
PromotionProviderPublicDto,
PromotionRepositoryListPublicDto,
PromotionBranchListPublicDto,
PromotionPromoteConfigPublicDto,
UpdatePromotionConnectionDto,
UpdatePromotionProviderDto,
@ -35,6 +39,31 @@ export type { PromotionConnectionSummary };
const promotionsApiRoot = '/promotions';
export const fetchPromotionRepositories = async (
context: PublicApiContext,
providerId: string,
query: Partial<ListPromotionRepositoriesQueryDto> = {},
): Promise<PromotionRepositoryListPublicDto> =>
await request({
method: 'GET',
baseURL: context.baseUrl,
endpoint: `${promotionsApiRoot}/providers/${encodeURIComponent(providerId)}/repositories`,
data: query,
});
export const fetchPromotionBranches = async (
context: PublicApiContext,
providerId: string,
repositoryId: string,
query: Partial<ListPromotionBranchesQueryDto> = {},
): Promise<PromotionBranchListPublicDto> =>
await request({
method: 'GET',
baseURL: context.baseUrl,
endpoint: `${promotionsApiRoot}/providers/${encodeURIComponent(providerId)}/repositories/${encodeURIComponent(repositoryId)}/branches`,
data: query,
});
async function fetchAllPages<T>(
fetchPage: (cursor?: string) => Promise<{ data: T[]; nextCursor: string | null }>,
): Promise<T[]> {

View File

@ -3,17 +3,21 @@ import type {
CreatePromotionProviderDto,
PromotionDirection,
PromotionProviderAuthType,
PromotionProviderType,
PromotionSshKeyType,
UpdatePromotionConnectionDto,
UpdatePromotionProviderDto,
UpsertPromotionApplyConfigDto,
UpsertPromotionPromoteConfigDto,
} from '@n8n/api-types';
import { promotionProviderTypeCapabilities } from '@n8n/api-types';
import type { PromotionConnection, PromotionProvider } from './promotionsSettings.api';
export type ProviderFormState = {
name: string;
type: PromotionProviderType;
baseUrl: string;
/** Cannot change after creation. */
authType: PromotionProviderAuthType;
keyType: PromotionSshKeyType;
@ -22,6 +26,20 @@ export type ProviderFormState = {
regenerateKey: boolean;
};
export type PromotionProviderChange = {
id: string;
hostChanged: boolean;
credentialsChanged: boolean;
};
export const isSamePromotionHostUrl = (current: string, next: string): boolean => {
try {
return new URL(current).href.replace(/\/$/, '') === new URL(next).href.replace(/\/$/, '');
} catch {
return false;
}
};
type DirectionFormState = {
enabled: boolean;
/** Keep this name when updating the settings. If absent, the API uses the direction label. */
@ -32,6 +50,7 @@ export type ConnectionFormState = {
name: string;
providerId: string;
remoteUrl: string;
repositoryPath?: string;
apply: DirectionFormState & { branchName: string };
promote: DirectionFormState & { baseBranchName: string; createBranchOnPromotion: boolean };
};
@ -44,6 +63,8 @@ export type ConnectionWrite =
export const emptyProviderForm = (): ProviderFormState => ({
name: '',
type: 'git',
baseUrl: '',
authType: 'ssh-key',
keyType: 'ed25519',
username: '',
@ -54,17 +75,20 @@ export const emptyProviderForm = (): ProviderFormState => ({
export const providerFormFrom = (provider: PromotionProvider): ProviderFormState => ({
...emptyProviderForm(),
name: provider.name,
type: provider.type,
baseUrl: 'baseUrl' in provider.config ? provider.config.baseUrl : '',
authType: provider.authType,
keyType: 'keyType' in provider.config ? provider.config.keyType : 'ed25519',
});
/** New forms must set whether promotion creates a branch. */
/** Keep field order aligned with connectionFormFrom for JSON-based dirty checks. */
export const emptyConnectionForm = (): ConnectionFormState => ({
name: '',
providerId: '',
remoteUrl: '',
apply: { enabled: false, branchName: '' },
promote: { enabled: false, baseBranchName: '', createBranchOnPromotion: false },
repositoryPath: undefined,
apply: { enabled: false, name: undefined, branchName: '' },
promote: { enabled: false, name: undefined, baseBranchName: '', createBranchOnPromotion: false },
});
export const connectionFormFrom = (connection: PromotionConnection): ConnectionFormState => {
@ -74,6 +98,7 @@ export const connectionFormFrom = (connection: PromotionConnection): ConnectionF
name: connection.name,
providerId: connection.provider.id,
remoteUrl: connection.target.remoteUrl,
repositoryPath: connection.target.repositoryPath,
apply: {
enabled: apply !== undefined,
name: apply?.name,
@ -106,11 +131,18 @@ export const buildProviderCreatePayload = (
form: ProviderFormState,
): CreatePromotionProviderDto => ({
name: form.name.trim(),
type: 'git',
type: form.type,
...(promotionProviderTypeCapabilities[form.type].hasHostApi && {
config: { schemaVersion: 1, baseUrl: form.baseUrl.trim() },
}),
auth:
form.authType === 'ssh-key'
? { authType: 'ssh-key', keyType: form.keyType }
: { authType: 'token', username: form.username.trim(), password: form.password },
: {
authType: 'token',
...(form.type === 'git' && { username: form.username.trim() }),
password: form.password,
},
});
/** If credentials are absent, the API keeps them. SSH rotation keeps the key type. */
@ -122,13 +154,20 @@ export const buildProviderUpdatePayload = (
const name = form.name.trim();
if (name !== current.name) payload.name = name;
if (
'baseUrl' in current.config &&
!isSamePromotionHostUrl(current.config.baseUrl, form.baseUrl)
) {
payload.config = { schemaVersion: 1, baseUrl: form.baseUrl.trim() };
}
const username = form.username.trim();
if (form.authType === 'ssh-key' && form.regenerateKey) {
payload.auth = { authType: 'ssh-key' };
} else if (form.authType === 'token' && form.username.trim() && form.password) {
} else if (form.authType === 'token' && form.password && (current.type !== 'git' || username)) {
payload.auth = {
authType: 'token',
username: form.username.trim(),
...(current.type === 'git' && { username }),
password: form.password,
};
}
@ -161,7 +200,11 @@ export const planConnectionWrites = (
if (name !== current.name) payload.name = name;
const remoteUrl = form.remoteUrl.trim();
if (remoteUrl !== current.target.remoteUrl) {
// A matching URL still needs a write when it converts a saved URL target to a path.
if (
remoteUrl !== current.target.remoteUrl ||
form.repositoryPath !== current.target.repositoryPath
) {
payload.target = { schemaVersion: 1, remoteUrl };
}

View File

@ -1,16 +1,28 @@
import type { PromotionBranchListPublicDto, PromotionRepository } from '@n8n/api-types';
import { ResponseError, type IUser } from '@n8n/rest-api-client';
import { useUsersStore } from '@n8n/stores/users.store';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { createTestingPinia } from '@pinia/testing';
import { flushPromises } from '@vue/test-utils';
import { screen, waitFor, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { mock } from 'vitest-mock-extended';
import { createComponentRenderer } from '@/__tests__/render';
import { getDropdownItems } from '@/__tests__/utils';
import { getDropdownItems, mockedStore } from '@/__tests__/utils';
import { MODAL_CANCEL, MODAL_CONFIRM } from '@/app/constants';
import {
invalidateInstancePromotionConnection,
useInstancePromotionConnection,
} from '../composables/useInstancePromotionConnection';
import type * as PromotionsApi from '../promotionsSettings.api';
import type { PromotionConnection, PromotionProvider } from '../promotionsSettings.api';
import PromotionsSettingsView from './PromotionsSettingsView.vue';
const api = vi.hoisted(() => ({
fetchPromotionProviders: vi.fn<typeof PromotionsApi.fetchPromotionProviders>(),
fetchPromotionRepositories: vi.fn<typeof PromotionsApi.fetchPromotionRepositories>(),
fetchPromotionBranches: vi.fn<typeof PromotionsApi.fetchPromotionBranches>(),
fetchPromotionProvider: vi.fn<typeof PromotionsApi.fetchPromotionProvider>(),
createPromotionProvider: vi.fn<typeof PromotionsApi.createPromotionProvider>(),
updatePromotionProvider: vi.fn<typeof PromotionsApi.updatePromotionProvider>(),
@ -22,6 +34,8 @@ const api = vi.hoisted(() => ({
upsertPromotionApplyConfig: vi.fn<typeof PromotionsApi.upsertPromotionApplyConfig>(),
upsertPromotionPromoteConfig: vi.fn<typeof PromotionsApi.upsertPromotionPromoteConfig>(),
deletePromotionConfig: vi.fn<typeof PromotionsApi.deletePromotionConfig>(),
clonePromotionCheckout: vi.fn<typeof PromotionsApi.clonePromotionCheckout>(),
disconnectPromotionCheckout: vi.fn<typeof PromotionsApi.disconnectPromotionCheckout>(),
}));
vi.mock('../promotionsSettings.api', () => api);
@ -88,6 +102,52 @@ const promoteConfig = (baseBranchName: string, createBranchOnPromotion = false)
...timestamps,
});
const gitlabProvider = (overrides: Partial<PromotionProvider> = {}) =>
sshProvider({
id: 'provider-gitlab',
name: 'GitLab',
type: 'gitlab',
authType: 'token',
config: { schemaVersion: 1, baseUrl: 'https://gitlab.example.com' },
...overrides,
});
const repository: PromotionRepository = {
id: '42',
name: 'Workflows',
fullPath: 'team/workflows',
remoteUrl: 'https://gitlab.example.com/team/workflows.git',
defaultBranch: 'main',
};
const otherRepository = {
...repository,
id: '43',
fullPath: 'team/other',
remoteUrl: 'https://gitlab.example.com/team/other.git',
};
const requestTargetOf = (repo: PromotionRepository) => ({
schemaVersion: 1 as const,
remoteUrl: repo.remoteUrl,
});
const targetOf = (repo: PromotionRepository) => ({
...requestTargetOf(repo),
repositoryPath: repo.fullPath,
});
const gitlabConnection = (overrides: Partial<PromotionConnection> = {}) =>
instanceConnection({
provider: summaryOf(gitlabProvider()),
target: targetOf(repository),
configs: { apply: applyConfig('main') },
...overrides,
});
const mockGitlabConnection = (saved = gitlabConnection()) => {
api.fetchPromotionProviders.mockResolvedValue([saved.provider]);
api.fetchPromotionProvider.mockResolvedValue(gitlabProvider());
api.updatePromotionProvider.mockResolvedValue(gitlabProvider());
api.fetchPromotionConnections.mockResolvedValue([saved]);
api.fetchPromotionConnection.mockResolvedValue(saved);
return saved;
};
const renderView = createComponentRenderer(PromotionsSettingsView);
const renderReadyView = async () => {
@ -122,9 +182,50 @@ const save = async () => {
await userEvent.click(within(bar).getByRole('button', { name: /save settings|retry/i }));
};
const selectedValue = (field: string) =>
within(screen.getByTestId(`promotion-connection-${field}-select`)).getByRole('combobox');
const discard = async () =>
userEvent.click(
within(screen.getByTestId('promotion-connection-save-bar')).getByRole('button', {
name: /discard/i,
}),
);
const selectRepository = async (repo = otherRepository) =>
selectInDialog(screen.getByTestId('promotion-connection-repository-select'), repo.fullPath);
const selectBranches = async (apply = 'main', promote?: string) => {
await selectInDialog(screen.getByTestId('promotion-connection-apply-branch-select'), apply);
if (promote)
await selectInDialog(screen.getByTestId('promotion-connection-promote-branch-select'), promote);
};
const fillGitlabProvider = async (fields: { name?: string; baseUrl?: string; token?: string }) => {
for (const [field, value] of [
['name', fields.name],
['base-url', fields.baseUrl],
['password', fields.token],
]) {
if (value === undefined) continue;
const input = screen.getByTestId(`promotion-provider-${field}-input`);
await userEvent.clear(input);
await userEvent.type(input, value);
}
};
const saveProvider = async () => {
await userEvent.click(screen.getByTestId('promotion-provider-save-button'));
await waitFor(() =>
expect(screen.queryByTestId('promotion-provider-dialog')).not.toBeInTheDocument(),
);
};
const editGitlabProvider = async (fields: Parameters<typeof fillGitlabProvider>[0]) => {
await openProviderDialog(screen.getAllByTestId('promotion-provider-row')[0]);
await screen.findByDisplayValue('https://gitlab.example.com');
await fillGitlabProvider(fields);
await saveProvider();
};
describe('PromotionsSettingsView', () => {
beforeEach(() => {
vi.resetAllMocks();
invalidateInstancePromotionConnection();
createTestingPinia();
api.fetchPromotionProviders.mockResolvedValue([]);
api.fetchPromotionProvider.mockResolvedValue(sshProvider());
@ -132,9 +233,518 @@ describe('PromotionsSettingsView', () => {
api.fetchPromotionConnections.mockResolvedValue([]);
api.deletePromotionProvider.mockResolvedValue(undefined);
api.deletePromotionConfig.mockResolvedValue(undefined);
api.fetchPromotionRepositories.mockResolvedValue({
data: [repository, otherRepository],
nextCursor: null,
});
api.fetchPromotionBranches.mockResolvedValue({
data: [
{ name: 'main', isDefault: true },
{ name: 'release/test', isDefault: false },
],
nextCursor: null,
});
});
describe('GitLab connections', () => {
it('creates a provider and a connection without losing the draft', async () => {
const cachedConnection = useInstancePromotionConnection();
await cachedConnection.load();
const created = gitlabConnection({
configs: { apply: applyConfig('main'), promote: promoteConfig('release/test', true) },
});
const pending = createDeferredPromise<PromotionConnection>();
api.createPromotionProvider.mockResolvedValue({
provider: gitlabProvider(),
publicKey: null,
});
api.fetchPromotionProviders
.mockResolvedValueOnce([])
.mockRejectedValueOnce(new Error('The provider refresh failed.'))
.mockResolvedValue([summaryOf(gitlabProvider())]);
api.createPromotionConnection.mockReturnValueOnce(pending.promise);
await renderReadyView();
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), 'Production');
await userEvent.click(screen.getByTestId('promotion-connection-add-provider'));
await selectInDialog(screen.getByTestId('promotion-provider-type-select'), 'GitLab');
await fillGitlabProvider({
name: 'GitLab',
baseUrl: 'https://gitlab.example.com',
token: 'test-token',
});
await saveProvider();
const refreshError = await screen.findByTestId('promotion-providers-load-error');
expect(selectedValue('provider')).toHaveValue('');
await userEvent.click(within(refreshError).getByRole('button', { name: 'Retry' }));
await screen.findByTestId('promotion-provider-row');
await selectProvider('GitLab');
expect(screen.getByTestId('promotion-connection-name-input')).toHaveValue('Production');
expect(screen.queryByTestId('promotion-connection-remote-url-input')).not.toBeInTheDocument();
await selectRepository(repository);
await userEvent.click(screen.getByTestId('promotion-connection-apply-toggle'));
await userEvent.click(screen.getByTestId('promotion-connection-promote-toggle'));
await selectBranches('main', 'release/test');
expect(selectedValue('repository')).toHaveAccessibleName(/^Repository/);
expect(selectedValue('apply-branch')).toHaveAccessibleName(/^Apply branch/);
expect(selectedValue('promote-branch')).toHaveAccessibleName(/^Promote base branch/);
await userEvent.click(screen.getByTestId('promotion-connection-create-branch-toggle'));
await save();
expect(api.createPromotionConnection).toHaveBeenCalledWith(expect.anything(), {
name: 'Production',
scope: 'instance',
providerId: 'provider-gitlab',
target: requestTargetOf(repository),
configs: {
apply: { settings: { schemaVersion: 1, branchName: 'main' } },
promote: {
settings: {
schemaVersion: 1,
baseBranchName: 'release/test',
createBranchOnPromotion: true,
},
},
},
});
api.fetchPromotionConnections.mockResolvedValue([created]);
api.fetchPromotionConnection.mockResolvedValue(created);
api.fetchPromotionProvider.mockResolvedValue(gitlabProvider());
api.updatePromotionProvider.mockResolvedValue(gitlabProvider());
await editGitlabProvider({ name: 'GitLab renamed' });
pending.resolve(created);
await flushPromises();
expect(mockShowMessage).toHaveBeenCalledWith({
title: 'Connection created',
type: 'success',
});
expect(
within(screen.getByTestId('promotion-connection-save-bar')).queryByRole('button', {
name: /save settings/i,
}),
).not.toBeInTheDocument();
await cachedConnection.load();
expect(cachedConnection.hasPromoteConfig.value).toBe(true);
});
it('clears dependent branches, supports discard, and converts a saved URL target to a path', async () => {
const saved = mockGitlabConnection(
gitlabConnection({
target: { schemaVersion: 1, remoteUrl: repository.remoteUrl },
configs: { apply: applyConfig('main'), promote: promoteConfig('release/test', true) },
}),
);
api.fetchPromotionProviders.mockResolvedValue([saved.provider, summaryOf(sshProvider())]);
api.updatePromotionConnection
.mockResolvedValueOnce({ ...saved, target: targetOf(repository) })
.mockResolvedValue({ ...saved, target: targetOf(otherRepository) });
await renderReadyView();
const hint = screen.getByText('Select a repository again to load its branches', {
selector: '[role="status"]',
});
expect(selectedValue('repository')).toHaveAttribute('aria-describedby', hint.id);
await selectRepository(repository);
expect(selectedValue('apply-branch')).toHaveValue('main');
expect(selectedValue('promote-branch')).toHaveValue('release/test');
await save();
expect(api.updatePromotionConnection).toHaveBeenCalledWith(expect.anything(), saved.id, {
target: requestTargetOf(repository),
});
await selectRepository();
expect(selectedValue('apply-branch')).toHaveValue('');
expect(selectedValue('promote-branch')).toHaveValue('');
expect(
within(screen.getByTestId('promotion-connection-save-bar')).getByRole('button', {
name: /save settings/i,
}),
).toBeDisabled();
await discard();
expect(selectedValue('repository')).toHaveValue(repository.fullPath);
expect(selectedValue('promote-branch')).toHaveValue('release/test');
await selectRepository();
await selectBranches('main', 'release/test');
await save();
expect(api.updatePromotionConnection).toHaveBeenCalledWith(expect.anything(), saved.id, {
target: requestTargetOf(otherRepository),
});
expect(api.upsertPromotionApplyConfig).not.toHaveBeenCalled();
expect(api.upsertPromotionPromoteConfig).not.toHaveBeenCalled();
await selectProvider('Production key');
expect(screen.getByTestId('promotion-connection-remote-url-input')).toHaveValue('');
expect(screen.getByTestId('promotion-connection-apply-branch-input')).toHaveValue('');
});
it('loads saved branches by path without resolving the repository browse page', async () => {
const saved = mockGitlabConnection();
api.fetchPromotionRepositories.mockRejectedValueOnce(new Error('GitLab is unavailable.'));
await renderReadyView();
await screen.findByRole('alert');
expect(selectedValue('apply-branch')).toBeEnabled();
expect(api.fetchPromotionBranches).toHaveBeenCalledWith(
expect.anything(),
saved.provider.id,
repository.fullPath,
expect.anything(),
);
expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
await flushPromises();
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' renamed');
api.updatePromotionConnection.mockResolvedValue({ ...saved, name: 'Production renamed' });
await save();
expect(api.updatePromotionConnection).toHaveBeenCalledWith(expect.anything(), saved.id, {
name: 'Production renamed',
});
});
it('blocks dependent writes after target failure and retries only unsaved writes', async () => {
const saved = mockGitlabConnection(
gitlabConnection({
configs: { apply: applyConfig('main'), promote: promoteConfig('main') },
}),
);
api.updatePromotionConnection
.mockRejectedValueOnce(new Error('Target update failed.'))
.mockResolvedValue({ ...saved, target: targetOf(otherRepository) });
api.upsertPromotionApplyConfig.mockResolvedValue(applyConfig('release/test'));
api.upsertPromotionPromoteConfig
.mockRejectedValueOnce(new Error('Settings update failed.'))
.mockResolvedValue(promoteConfig('release/test'));
await renderReadyView();
await selectRepository();
await selectBranches('release/test', 'release/test');
await save();
const failure = await screen.findByTestId('promotion-connection-partial-save');
expect(failure).toHaveTextContent('Apply settings');
expect(failure).toHaveTextContent('Promote settings');
expect(api.upsertPromotionApplyConfig).not.toHaveBeenCalled();
expect(api.upsertPromotionPromoteConfig).not.toHaveBeenCalled();
await save();
await waitFor(() => expect(api.upsertPromotionPromoteConfig).toHaveBeenCalledTimes(1));
await save();
await waitFor(() =>
expect(screen.queryByTestId('promotion-connection-partial-save')).not.toBeInTheDocument(),
);
expect(api.updatePromotionConnection).toHaveBeenCalledTimes(2);
expect(api.upsertPromotionApplyConfig).toHaveBeenCalledTimes(1);
expect(api.upsertPromotionPromoteConfig).toHaveBeenCalledTimes(2);
});
it('keeps the saved repository and its branches after a host edit', async () => {
const saved = mockGitlabConnection();
const movedRepository = {
...repository,
remoteUrl: 'https://other.example.com/team/workflows.git',
};
api.fetchPromotionConnection
.mockResolvedValueOnce(saved)
.mockResolvedValue({ ...saved, target: targetOf(movedRepository) });
await renderReadyView();
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' draft');
api.fetchPromotionRepositories.mockResolvedValue({
data: [movedRepository],
nextCursor: null,
});
await editGitlabProvider({ baseUrl: 'https://other.example.com', token: 'test-token' });
await waitFor(() => expect(selectedValue('repository')).toHaveValue(repository.fullPath));
expect(selectedValue('apply-branch')).toBeEnabled();
expect(selectedValue('apply-branch')).toHaveValue('main');
api.updatePromotionConnection.mockResolvedValue({ ...saved, name: 'Production draft' });
await save();
await waitFor(() =>
expect(api.updatePromotionConnection).toHaveBeenCalledWith(expect.anything(), saved.id, {
name: 'Production draft',
}),
);
});
it('keeps same-path repository drafts scoped to their provider during host edits', async () => {
const saved = mockGitlabConnection();
const providerB = gitlabProvider({ id: 'provider-b', name: 'GitLab B' });
const movedRepository = {
...repository,
remoteUrl: 'https://other.example.com/team/workflows.git',
};
api.fetchPromotionProviders
.mockResolvedValueOnce([saved.provider, summaryOf(providerB)])
.mockResolvedValue([summaryOf(providerB), saved.provider]);
api.fetchPromotionProvider
.mockResolvedValueOnce(gitlabProvider())
.mockResolvedValue(providerB);
api.updatePromotionProvider
.mockResolvedValueOnce(
gitlabProvider({ config: { schemaVersion: 1, baseUrl: 'https://other.example.com' } }),
)
.mockResolvedValue({
...providerB,
config: { schemaVersion: 1, baseUrl: 'https://new.example.com' },
});
api.fetchPromotionConnection
.mockResolvedValueOnce(saved)
.mockResolvedValue({ ...saved, target: targetOf(movedRepository) });
await renderReadyView();
await selectProvider('GitLab B');
await selectRepository(repository);
await selectBranches();
await editGitlabProvider({ baseUrl: 'https://other.example.com', token: 'test-token' });
expect(selectedValue('provider')).toHaveValue('GitLab B');
expect(selectedValue('repository')).toHaveValue(repository.fullPath);
await editGitlabProvider({ baseUrl: 'https://new.example.com', token: 'test-token' });
expect(selectedValue('repository')).toHaveValue('');
expect(selectedValue('apply-branch')).toBeDisabled();
expect(
within(screen.getByTestId('promotion-connection-save-bar')).getByRole('button', {
name: /save settings/i,
}),
).toBeDisabled();
});
it.each(['saved', 'new'] as const)(
'clears a repository picked from the old host after a host edit on a %s connection',
async (state) => {
const saved = mockGitlabConnection();
api.fetchPromotionConnections.mockResolvedValue(state === 'saved' ? [saved] : []);
await renderReadyView();
if (state === 'new') {
await selectProvider('GitLab');
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), 'Production');
await userEvent.click(screen.getByTestId('promotion-connection-apply-toggle'));
}
await selectRepository();
await selectBranches();
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' draft');
await editGitlabProvider({ baseUrl: 'https://other.example.com', token: 'test-token' });
expect(selectedValue('repository')).toHaveValue('');
expect(selectedValue('apply-branch')).toBeDisabled();
expect(screen.getByTestId('promotion-connection-name-input')).toHaveValue(
'Production draft',
);
expect(
within(screen.getByTestId('promotion-connection-save-bar')).getByRole('button', {
name: /save settings/i,
}),
).toBeDisabled();
},
);
it('refreshes token access and ignores pending discovery from the previous token', async () => {
mockGitlabConnection();
const old = createDeferredPromise<PromotionBranchListPublicDto>();
api.fetchPromotionBranches
.mockReturnValueOnce(old.promise)
.mockRejectedValueOnce(new Error('Repository access changed.'));
await renderReadyView();
await editGitlabProvider({ token: 'replacement-token' });
expect(api.updatePromotionProvider).toHaveBeenCalledWith(
expect.anything(),
'provider-gitlab',
{ auth: { authType: 'token', password: 'replacement-token' } },
);
expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(2);
expect(api.fetchPromotionBranches).toHaveBeenCalledTimes(2);
old.resolve({ data: [{ name: 'old-only', isDefault: false }], nextCursor: 'old-page' });
await flushPromises();
expect(screen.getByRole('alert')).toHaveTextContent('Repository access changed.');
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
expect(selectedValue('apply-branch')).toHaveValue('main');
});
it.each(['selected', 'original'] as const)(
'does not restore cleared IDs after editing the %s provider during a failed save',
async (editedProvider) => {
const saved = mockGitlabConnection();
const providerB = gitlabProvider({
id: 'provider-b',
name: 'GitLab B',
});
api.fetchPromotionProviders.mockResolvedValue([saved.provider, summaryOf(providerB)]);
api.fetchPromotionConnection.mockResolvedValueOnce(saved).mockResolvedValue({
...saved,
target: { schemaVersion: 1, remoteUrl: saved.target.remoteUrl },
});
const pending = createDeferredPromise<PromotionConnection>();
api.updatePromotionConnection.mockReturnValueOnce(pending.promise);
await renderReadyView();
if (editedProvider === 'original') await selectProvider('GitLab B');
await selectRepository();
await selectBranches();
await save();
await editGitlabProvider({ baseUrl: 'https://other.example.com', token: 'test-token' });
const failure = new Error('The provider changed during the update.');
pending.reject(failure);
await waitFor(() => expect(api.fetchPromotionConnection).toHaveBeenCalledTimes(3));
expect(mockShowError).toHaveBeenCalledWith(failure, "Couldn't save the connection");
if (editedProvider === 'original') {
expect(selectedValue('provider')).toHaveValue('GitLab B');
expect(selectedValue('apply-branch')).toBeEnabled();
}
await discard();
expect(selectedValue('apply-branch')).toBeDisabled();
expect(api.upsertPromotionApplyConfig).not.toHaveBeenCalled();
},
);
it.each(['success', 'failure'] as const)(
'ignores an older refresh %s after a successful save',
async (outcome) => {
const saved = mockGitlabConnection();
const pending = createDeferredPromise<PromotionConnection>();
api.fetchPromotionConnection
.mockResolvedValueOnce(saved)
.mockReturnValueOnce(pending.promise);
api.updatePromotionConnection.mockResolvedValue({
...saved,
target: targetOf(otherRepository),
});
await renderReadyView();
await editGitlabProvider({ name: 'GitLab renamed' });
await selectRepository();
await selectBranches();
await save();
await flushPromises();
if (outcome === 'success') pending.resolve(saved);
else pending.reject(new Error('The older refresh failed.'));
await flushPromises();
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' draft');
await discard();
expect(selectedValue('repository')).toHaveValue(otherRepository.fullPath);
expect(screen.queryByTestId('promotion-providers-load-error')).not.toBeInTheDocument();
expect(mockShowError).not.toHaveBeenCalled();
},
);
it.each(['transient', 'forbidden'] as const)(
'handles a %s refresh failure without treating it as an initial load',
async (failure) => {
mockGitlabConnection();
api.fetchPromotionProviders
.mockResolvedValueOnce([summaryOf(gitlabProvider())])
.mockRejectedValueOnce(
failure === 'transient'
? new Error('The refresh failed.')
: new ResponseError('Access denied.', { httpStatusCode: 403 }),
);
await renderReadyView();
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' draft');
await editGitlabProvider({ name: 'GitLab renamed' });
const loadError = await screen.findByTestId('promotion-providers-load-error');
if (failure === 'transient')
expect(screen.getByTestId('promotion-connection-name-input')).toHaveValue(
'Production draft',
);
else expect(screen.queryByTestId('promotion-connection-form')).not.toBeInTheDocument();
await userEvent.click(within(loadError).getByRole('button', { name: 'Retry' }));
expect(await screen.findByTestId('promotion-connection-name-input')).toHaveValue(
failure === 'transient' ? 'Production draft' : 'Production',
);
},
);
it.each(['unchanged', 'replaced'] as const)(
'keeps checkout state correct when the repository is %s during a clone',
async (state) => {
mockedStore(useUsersStore).currentUser = mock<IUser>({
globalScopes: ['gitConnection:clone', 'gitConnection:pull'],
});
const saved = mockGitlabConnection();
const updated = { ...saved, target: targetOf(otherRepository) };
api.fetchPromotionConnection.mockResolvedValueOnce(saved).mockResolvedValue(updated);
api.updatePromotionConnection.mockResolvedValue(updated);
const pending =
createDeferredPromise<Awaited<ReturnType<typeof PromotionsApi.clonePromotionCheckout>>>();
api.clonePromotionCheckout.mockReturnValueOnce(pending.promise);
await renderReadyView();
await userEvent.click(screen.getByTestId('promotion-checkout-connect'));
if (state === 'replaced') {
await selectRepository();
await selectBranches();
await save();
await flushPromises();
}
pending.resolve({
connectionId: saved.id,
configId: 'config-apply',
direction: 'apply',
branchName: 'main',
hasCheckout: true,
});
await flushPromises();
if (state === 'replaced')
expect(screen.getByTestId('apply-instance-button')).toBeDisabled();
else expect(screen.getByTestId('apply-instance-button')).toBeEnabled();
expect(api.fetchPromotionConnection).toHaveBeenCalledTimes(state === 'replaced' ? 2 : 1);
},
);
});
describe('providers', () => {
it('validates GitLab URL and token input and retries creation without closing', async () => {
api.createPromotionProvider
.mockRejectedValueOnce(new Error('GitLab authentication failed.'))
.mockResolvedValue({ provider: gitlabProvider(), publicKey: null });
await renderReadyView();
await openProviderDialog();
await selectInDialog(screen.getByTestId('promotion-provider-type-select'), 'GitLab');
expect(screen.queryByTestId('promotion-provider-auth-type-select')).not.toBeInTheDocument();
expect(screen.queryByTestId('promotion-provider-username-input')).not.toBeInTheDocument();
await fillGitlabProvider({ name: 'GitLab', baseUrl: 'https://gitlab.example.com/gitlab' });
expect(screen.getByTestId('promotion-provider-save-button')).toBeDisabled();
await fillGitlabProvider({ token: 'test-token' });
await userEvent.click(screen.getByTestId('promotion-provider-save-button'));
expect(await screen.findByRole('alert')).toHaveTextContent('GitLab authentication failed.');
expect(screen.getByTestId('promotion-provider-password-input')).toHaveValue('test-token');
expect(mockShowError).not.toHaveBeenCalled();
await saveProvider();
expect(api.createPromotionProvider).toHaveBeenLastCalledWith(expect.anything(), {
name: 'GitLab',
type: 'gitlab',
config: { schemaVersion: 1, baseUrl: 'https://gitlab.example.com/gitlab' },
auth: { authType: 'token', password: 'test-token' },
});
});
it('renames a GitLab provider without changing its token, equivalent URL, or discovery', async () => {
mockGitlabConnection();
await renderReadyView();
await editGitlabProvider({
name: 'GitLab renamed',
baseUrl: 'https://GITLAB.example.com:443/',
});
expect(api.updatePromotionProvider).toHaveBeenCalledWith(
expect.anything(),
'provider-gitlab',
{ name: 'GitLab renamed' },
);
expect(api.fetchPromotionRepositories).toHaveBeenCalledTimes(1);
expect(api.fetchPromotionBranches).toHaveBeenCalledTimes(1);
});
it('requires a new token for a host edit and clears failed validation after editing', async () => {
mockGitlabConnection();
api.updatePromotionProvider
.mockRejectedValueOnce(new Error('GitLab authentication failed.'))
.mockResolvedValue(gitlabProvider());
await renderReadyView();
await openProviderDialog(screen.getByTestId('promotion-provider-row'));
await screen.findByDisplayValue('https://gitlab.example.com');
await fillGitlabProvider({ baseUrl: 'https://gitlab.example.com/gitlab' });
expect(screen.getByTestId('promotion-provider-save-button')).toBeDisabled();
await fillGitlabProvider({ token: 'expired-token' });
await userEvent.click(screen.getByTestId('promotion-provider-save-button'));
expect(await screen.findByTestId('promotion-provider-save-error')).toHaveTextContent(
'GitLab authentication failed.',
);
await fillGitlabProvider({ token: 'replacement-token' });
expect(screen.queryByTestId('promotion-provider-save-error')).not.toBeInTheDocument();
await saveProvider();
expect(api.updatePromotionProvider).toHaveBeenLastCalledWith(
expect.anything(),
'provider-gitlab',
{
config: { schemaVersion: 1, baseUrl: 'https://gitlab.example.com/gitlab' },
auth: { authType: 'token', password: 'replacement-token' },
},
);
});
it('adds an SSH provider and shows its deploy key', async () => {
api.createPromotionProvider.mockResolvedValue({
provider: sshProvider(),
@ -477,6 +1087,32 @@ describe('PromotionsSettingsView', () => {
expect(mockShowMessage).toHaveBeenCalled();
});
it('saves independent branches after a name failure and retries only the name', async () => {
const saved = instanceConnection({ configs: { apply: applyConfig('main') } });
api.fetchPromotionProviders.mockResolvedValue([saved.provider]);
api.fetchPromotionConnections.mockResolvedValue([saved]);
api.fetchPromotionConnection.mockResolvedValue(saved);
api.updatePromotionConnection
.mockRejectedValueOnce(new Error('Name update failed.'))
.mockResolvedValue({
...saved,
name: 'Production renamed',
configs: { apply: applyConfig('main-next') },
});
api.upsertPromotionApplyConfig.mockResolvedValue(applyConfig('main-next'));
await renderReadyView();
await userEvent.type(screen.getByTestId('promotion-connection-name-input'), ' renamed');
await userEvent.type(screen.getByTestId('promotion-connection-apply-branch-input'), '-next');
await save();
await screen.findByTestId('promotion-connection-partial-save');
await save();
await waitFor(() =>
expect(screen.queryByTestId('promotion-connection-partial-save')).not.toBeInTheDocument(),
);
expect(api.updatePromotionConnection).toHaveBeenCalledTimes(2);
expect(api.upsertPromotionApplyConfig).toHaveBeenCalledTimes(1);
});
it('deletes Apply settings when Apply is turned off', async () => {
api.fetchPromotionProviders.mockResolvedValue([summaryOf(sshProvider())]);
const saved = instanceConnection({ configs: { apply: applyConfig('main') } });

View File

@ -12,10 +12,12 @@ import {
N8nSettingsSection,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { ResponseError } from '@n8n/rest-api-client';
import { useRootStore } from '@n8n/stores/useRootStore';
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useLatestFetch } from '@/app/composables/useLatestFetch';
import PromoteApplyInstanceSection from '../components/PromoteApplyInstanceSection.vue';
import PromotionConnectionForm from '../components/PromotionConnectionForm.vue';
import PromotionProviderDialog from '../components/PromotionProviderDialog.vue';
@ -27,16 +29,19 @@ import {
type PromotionConnection,
type PromotionProviderSummary,
} from '../promotionsSettings.api';
import type { PromotionProviderChange } from '../promotionsSettings.utils';
const i18n = useI18n();
const toast = useToast();
const rootStore = useRootStore();
const documentTitle = useDocumentTitle();
const { next: nextLoad } = useLatestFetch();
const providers = ref<PromotionProviderSummary[]>([]);
const connection = ref<PromotionConnection | null>(null);
const isInitialLoading = ref(true);
const loadError = ref(false);
const hasLoaded = ref(false);
const dialogOpen = ref(false);
const editingId = ref<string | undefined>(undefined);
const providerToFocus = ref<string | undefined>(undefined);
@ -48,32 +53,44 @@ const page = useTemplateRef<{ $el?: HTMLElement }>('page');
const connectionForm =
useTemplateRef<InstanceType<typeof PromotionConnectionForm>>('connectionForm');
let hasLoaded = false;
let connectionRevision = 0;
let pendingLoad: Promise<void> = Promise.resolve();
async function load() {
const isLatestLoad = nextLoad();
const revision = connectionRevision;
// Keep the list mounted during refresh so focus can return to a row.
// A retry after an error must show loading, so the form mounts with loaded data.
isInitialLoading.value = !hasLoaded || loadError.value;
isInitialLoading.value = !hasLoaded.value;
loadError.value = false;
try {
const [loadedProviders, connections] = await Promise.all([
fetchPromotionProviders(rootStore.publicApiContext),
fetchPromotionConnections(rootStore.publicApiContext, { scope: 'instance' }),
]);
providers.value = loadedProviders;
if (!isLatestLoad()) return;
const summary = connections[0];
connection.value = summary
const loadedConnection = summary
? await fetchPromotionConnection(rootStore.publicApiContext, summary.id)
: null;
if (!isLatestLoad()) return;
providers.value = loadedProviders;
// A save can finish while connection detail is loading.
if (revision === connectionRevision) connection.value = loadedConnection;
hasLoaded.value = true;
} catch (error) {
if (!isLatestLoad() || revision !== connectionRevision) return;
loadError.value = true;
providers.value = [];
connection.value = null;
if (
error instanceof ResponseError &&
(error.httpStatusCode === 401 || error.httpStatusCode === 403)
) {
hasLoaded.value = false;
providers.value = [];
connection.value = null;
}
toast.showError(error, i18n.baseText('settings.promotions.providers.error.title'));
} finally {
isInitialLoading.value = false;
hasLoaded = true;
if (isLatestLoad()) isInitialLoading.value = false;
}
}
@ -83,6 +100,7 @@ onMounted(async () => {
});
function authTypeLabel(provider: PromotionProviderSummary) {
if (provider.type === 'gitlab') return i18n.baseText('settings.promotions.providers.type.gitlab');
return i18n.baseText(
provider.authType === 'ssh-key'
? 'settings.promotions.providers.authType.sshKey'
@ -104,11 +122,18 @@ function openEditDialog(id: string) {
dialogOpen.value = true;
}
function onProviderSaved(id: string) {
function onProviderSaved(change: PromotionProviderChange) {
const { id } = change;
providerToFocus.value = id;
connectionForm.value?.applyProviderChange(change);
if (selectAfterCreate.value) {
connectionForm.value?.selectProvider(id);
selectAfterCreate.value = false;
pendingLoad = load().then(() => {
if (providers.value.some((provider) => provider.id === id)) {
connectionForm.value?.selectProvider(id);
}
});
return;
}
pendingLoad = load();
}
@ -119,11 +144,17 @@ function onProviderDeleted() {
}
function onConnectionSaved(saved: PromotionConnection) {
connectionRevision++;
connection.value = saved;
// The project header caches the instance connection per page load.
invalidateInstancePromotionConnection();
}
function onConnectionRefresh() {
invalidateInstancePromotionConnection();
void load();
}
async function focusProvider(id: string | undefined) {
await nextTick();
const row = id ? list.value?.querySelector<HTMLElement>(`[data-provider-id="${id}"]`) : undefined;
@ -163,7 +194,7 @@ async function onDialogOpenChange(open: boolean) {
data-test-id="promotion-providers-load-error"
@click:button="load"
/>
<template v-else>
<template v-if="!isInitialLoading && hasLoaded">
<div ref="list" :class="$style.list">
<N8nSettingsRowGroup v-for="provider in providers" :key="provider.id">
<N8nSettingsRow
@ -210,11 +241,12 @@ async function onDialogOpenChange(open: boolean) {
>
<N8nLoading2 v-if="isInitialLoading" :rows="3" :shrink-last="false" />
<PromotionConnectionForm
v-else-if="!loadError"
v-else-if="hasLoaded"
ref="connectionForm"
:providers="providers"
:connection="connection"
@saved="onConnectionSaved"
@refresh="onConnectionRefresh"
@add-provider="openCreateDialog(true)"
/>
</N8nSettingsSection>