Add and edit additional forges in UI (#5328)

Co-authored-by: Robert Kaussow <mail@thegeeklab.de>
This commit is contained in:
Anbraten
2025-07-19 08:49:19 +02:00
committed by GitHub
parent 8605000129
commit 49d112a210
10 changed files with 523 additions and 11 deletions

View File

@@ -526,5 +526,49 @@
"desc": "Pipelines created by the listed users never require approval."
}
},
"no_search_results": "No results found"
"no_search_results": "No results found",
"forges": "Forges",
"forges_desc": "Configure forges hosting repositories Woopecker should run for.",
"add_forge": "Add forge",
"show_forges": "Show forges",
"github": "GitHub",
"gitlab": "GitLab",
"bitbucket": "Bitbucket",
"bitbucket_dc": "Bitbucket Data Center",
"gitea": "Gitea",
"forgejo": "Forgejo",
"addon": "Addon",
"forge_type": "Forge type",
"oauth_client_id": "OAuth Client ID",
"oauth_client_secret": "OAuth Client Secret",
"oauth_host": "OAuth host",
"merge_ref": "Merge ref",
"merge_ref_desc": "Ref to use for merge base. This is used to determine the diff for pull requests.",
"public_only": "Public only",
"public_only_desc": "Only show public repositories.",
"git_username": "Git username",
"git_username_desc": "Username for the Git user.",
"git_password": "Git password",
"git_password_desc": "Password or personal access token for the Git user.",
"executable": "Executable",
"executable_desc": "Path to the addon executable.",
"save": "Save",
"add": "Add",
"skip_verify": "Skip SSL verification",
"skip_verify_desc": "Skip SSL verification for the API connection. This is not recommended for production use.",
"url": "URL",
"forge_managed_by_env": "The primary forge is managed by environment variables. Any changes to this forge will be reverted on a restart.",
"oauth_redirect_uri": "OAuth redirect URI",
"forge_created": "Forge created",
"advanced_options": "Advanced options",
"leave_empty_to_keep_current_value": "Leave empty to keep current value",
"forge_deleted": "Forge deleted",
"forge_delete_confirm": "Do you really want to delete this forge? This will also delete all repositories, users and pipelines related to this forge.",
"edit_forge": "Edit forge",
"delete_forge": "Delete forge",
"no_forges": "There are no forges yet.",
"use_this_redirect_uri_to_create": "Use this redirect URI to create or update the OAuth application. Go to {0} and setup the OAuth application.",
"developer_settings": "Developer settings",
"public_url_for_oauth_if": "Public URL for OAuth if different from URL ({0})",
"forge_saved": "Forge saved"
}

View File

@@ -0,0 +1,271 @@
<template>
<form @submit.prevent="submit">
<Warning v-if="!isNew && forge.id === 1" :text="$t('forge_managed_by_env')" />
<InputField v-slot="{ id }" :label="$t('forge_type')">
<SelectField
:id="id"
v-model="forgeType"
:options="[
{ value: 'github', text: $t('github') },
{ value: 'gitlab', text: $t('gitlab') },
{ value: 'gitea', text: $t('gitea') },
{ value: 'forgejo', text: $t('forgejo') },
{ value: 'bitbucket', text: $t('bitbucket') },
{ value: 'bitbucket-dc', text: $t('bitbucket_dc') },
{ value: 'addon', text: $t('addon') },
]"
required
/>
</InputField>
<InputField v-slot="{ id }" :label="$t('url')">
<TextField :id="id" v-model="forge.url" required />
</InputField>
<hr class="my-4 border-gray-800" />
<template v-if="forge.type && forge.url">
<InputField v-slot="{ id }" :label="$t('oauth_redirect_uri')">
<i18n-t keypath="use_this_redirect_uri_to_create" tag="p" class="mb-2">
<a rel="noopener noreferrer" :href="oauthAppForgeUrl" target="_blank" class="underline">{{
$t('developer_settings')
}}</a>
</i18n-t>
<TextField :id="id" :model-value="redirectUri" disabled />
</InputField>
<InputField v-slot="{ id }" :label="$t('oauth_client_id')">
<TextField :id="id" v-model="forge.client" required />
</InputField>
<InputField v-slot="{ id }" :label="$t('oauth_client_secret')">
<TextField
:id="id"
v-model="forge.client_secret"
:placeholder="isNew ? '' : $t('leave_empty_to_keep_current_value')"
:required="isNew"
/>
</InputField>
<Panel collapsable collapsed-by-default :title="$t('advanced_options')" class="mb-4">
<InputField v-slot="{ id }" :label="$t('oauth_host')">
<TextField :id="id" v-model="forge.oauth_host" :placeholder="$t('public_url_for_oauth_if', [forge.url])" />
</InputField>
<template v-if="forge.type === 'github'">
<InputField :label="$t('merge_ref')">
<Checkbox
:label="$t('merge_ref_desc')"
:model-value="getAdditionalOptions('github', 'merge-ref') ?? false"
@update:model-value="setAdditionalOptions('github', 'merge-ref', $event)"
/>
</InputField>
<InputField :label="$t('public_only')">
<Checkbox
:label="$t('public_only_desc')"
:model-value="getAdditionalOptions('github', 'public-only') ?? false"
@update:model-value="setAdditionalOptions('github', 'public-only', $event)"
/>
</InputField>
</template>
<template v-if="forge.type === 'bitbucket-dc'">
<InputField v-slot="{ id }" :label="$t('git_username')">
<p>{{ $t('git_username_desc') }}</p>
<TextField
:id="id"
:model-value="getAdditionalOptions('bitbucket-dc', 'git-username')"
@update:model-value="setAdditionalOptions('bitbucket-dc', 'git-username', $event)"
/>
</InputField>
<InputField v-slot="{ id }" :label="$t('git_password')">
<p>{{ $t('git_password_desc') }}</p>
<TextField
:id="id"
:model-value="getAdditionalOptions('bitbucket-dc', 'git-password')"
@update:model-value="setAdditionalOptions('bitbucket-dc', 'git-password', $event)"
/>
</InputField>
</template>
<template v-if="forge.type === 'addon'">
<InputField v-slot="{ id }" :label="$t('executable')">
<p>{{ $t('executable_desc') }}</p>
<TextField
:id="id"
:model-value="getAdditionalOptions('addon', 'executable')"
@update:model-value="setAdditionalOptions('addon', 'executable', $event)"
/>
</InputField>
</template>
<InputField :label="$t('skip_verify')">
<Checkbox
:label="$t('skip_verify_desc')"
:model-value="forge.skip_verify || false"
@update:model-value="forge!.skip_verify = $event"
/>
</InputField>
</Panel>
<div class="flex gap-2">
<Button :text="$t('cancel')" @click="forge = {}" />
<Button :is-loading="isSaving" type="submit" color="green" :text="isNew ? $t('add') : $t('save')" />
</div>
</template>
</form>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import Button from '~/components/atomic/Button.vue';
import Warning from '~/components/atomic/Warning.vue';
import Checkbox from '~/components/form/Checkbox.vue';
import InputField from '~/components/form/InputField.vue';
import SelectField from '~/components/form/SelectField.vue';
import TextField from '~/components/form/TextField.vue';
import Panel from '~/components/layout/Panel.vue';
import useConfig from '~/compositions/useConfig';
import type { Forge, ForgeType } from '~/lib/api/types';
defineProps<{
isNew?: boolean;
isSaving?: boolean;
}>();
const emit = defineEmits<{
(e: 'submit'): void;
}>();
const config = useConfig();
const forge = defineModel<Partial<Forge>>('forge', {
required: true,
});
interface GitHubAdditionOptions {
'merge-ref'?: boolean;
'public-only'?: boolean;
}
interface BitbucketAdditionOptions {
'git-username'?: string;
'git-password'?: string;
}
interface AddonAdditionOptions {
executable?: string;
}
function getAdditionalOptions<T extends keyof GitHubAdditionOptions>(
forgeType: 'github',
key: T,
): GitHubAdditionOptions[T];
// eslint-disable-next-line no-redeclare
function getAdditionalOptions<T extends keyof BitbucketAdditionOptions>(
forgeType: 'bitbucket-dc',
key: T,
): BitbucketAdditionOptions[T];
// eslint-disable-next-line no-redeclare
function getAdditionalOptions<T extends keyof AddonAdditionOptions>(
forgeType: 'addon',
key: T,
): AddonAdditionOptions[T];
// eslint-disable-next-line no-redeclare
function getAdditionalOptions<T extends keyof Record<string, unknown>>(_forgeType: ForgeType, key: T): unknown {
return forge.value?.additional_options?.[key];
}
function setAdditionalOptions<T extends keyof GitHubAdditionOptions>(
forgeType: 'github',
key: T,
value: GitHubAdditionOptions[T],
): void;
// eslint-disable-next-line no-redeclare
function setAdditionalOptions<T extends keyof BitbucketAdditionOptions>(
forgeType: 'bitbucket-dc',
key: T,
value: BitbucketAdditionOptions[T],
): void;
// eslint-disable-next-line no-redeclare
function setAdditionalOptions<T extends keyof AddonAdditionOptions>(
forgeType: 'addon',
key: T,
value: AddonAdditionOptions[T],
): void;
// eslint-disable-next-line no-redeclare
function setAdditionalOptions<T extends keyof Record<string, unknown>>(
_forgeType: ForgeType,
key: string,
value: T,
): void {
forge.value = {
...forge.value,
additional_options: {
...forge.value?.additional_options,
[key]: value,
},
};
}
const oauthAppForgeUrl = computed(() => {
if (!forge.value || !forge.value.type || !forge.value.url) {
return '';
}
const forgeUrl = `${forge.value.url.startsWith('http') ? '' : 'https://'}${forge.value.url.replace(/\/$/, '')}`;
switch (forge.value.type) {
case 'github':
return `${forgeUrl}/settings/applications/new`;
case 'gitlab':
return `${forgeUrl}/-/user_settings/applications`;
case 'gitea':
case 'forgejo':
return `${forgeUrl}/user/settings/applications`;
case 'bitbucket':
return `${forgeUrl}/account/settings/app-passwords`;
case 'bitbucket-dc':
return `${forgeUrl}/account/settings/app-passwords`;
default:
return '';
}
});
const forgeType = computed({
get: () => forge.value?.type ?? '',
set: (value) => {
let url = forge.value?.url || '';
if (value === 'github') {
url = url || 'github.com';
} else if (value === 'gitlab') {
url = url || 'gitlab.com';
} else if (value === 'bitbucket') {
url = url || 'bitbucket.org';
}
forge.value = { ...forge.value, url, type: value as ForgeType };
},
});
const redirectUri = computed(() => [window.location.origin, config.rootPath, 'authorize'].filter((a) => !!a).join('/'));
async function submit() {
if (!forge.value.url?.startsWith('http')) {
forge.value.url = `https://${forge.value.url}`;
}
if (forge.value.oauth_host === forge.value.url) {
forge.value.oauth_host = '';
}
if (forge.value.oauth_host && !forge.value.oauth_host.startsWith('http')) {
forge.value.oauth_host = `https://${forge.value.oauth_host}`;
}
emit('submit');
}
</script>

View File

@@ -63,6 +63,7 @@
<SvgIcon v-else-if="name === 'file-edit-outline'" :path="mdiFileEditOutline" size="1.3rem" />
<SvgIcon v-else-if="name === 'bug-outline'" :path="mdiBugOutline" size="1.3rem" />
<SvgIcon v-else-if="name === 'docker'" :path="mdiDocker" size="1.3rem" />
<SvgIcon v-else-if="name === 'forge'" :path="mdiCodeBraces" size="1.3rem" />
<SvgIcon v-else-if="name === 'visibility-private'" :path="mdiLockOutline" size="1.3rem" />
<SvgIcon v-else-if="name === 'visibility-internal'" :path="mdiLockOpenOutline" size="1.3rem" />
@@ -112,6 +113,7 @@ import {
mdiClockTimeEightOutline,
mdiClose,
mdiCloseCircle,
mdiCodeBraces,
mdiCog,
mdiCogOutline,
mdiConsole,
@@ -227,7 +229,8 @@ export type IconNames =
| 'user'
| 'org'
| 'cron'
| 'toolbox';
| 'toolbox'
| 'forge';
const props = defineProps<{
name: IconNames;

View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View File

@@ -1,4 +1,4 @@
export type ForgeType = 'github' | 'gitlab' | 'gitea' | 'bitbucket' | 'bitbucket-dc' | 'addon';
export type ForgeType = 'github' | 'gitlab' | 'gitea' | 'bitbucket' | 'bitbucket-dc' | 'addon' | 'forgejo';
export interface Forge {
id: number;

View File

@@ -219,56 +219,70 @@ const routes: RouteRecordRaw[] = [
{
path: `${rootPath}/admin`,
component: (): Component => import('~/views/admin/AdminSettingsWrapper.vue'),
props: true,
meta: { authentication: 'required' },
children: [
{
path: '',
name: 'admin-settings',
component: (): Component => import('~/views/admin/AdminInfo.vue'),
props: true,
},
{
path: 'secrets',
name: 'admin-settings-secrets',
component: (): Component => import('~/views/admin/AdminSecrets.vue'),
props: true,
},
{
path: 'registries',
name: 'admin-settings-registries',
component: (): Component => import('~/views/admin/AdminRegistries.vue'),
props: true,
},
{
path: 'repos',
name: 'admin-settings-repos',
component: (): Component => import('~/views/admin/AdminRepos.vue'),
props: true,
},
{
path: 'users',
name: 'admin-settings-users',
component: (): Component => import('~/views/admin/AdminUsers.vue'),
props: true,
},
{
path: 'orgs',
name: 'admin-settings-orgs',
component: (): Component => import('~/views/admin/AdminOrgs.vue'),
props: true,
},
{
path: 'agents',
name: 'admin-settings-agents',
component: (): Component => import('~/views/admin/AdminAgents.vue'),
props: true,
},
{
path: 'queue',
name: 'admin-settings-queue',
component: (): Component => import('~/views/admin/AdminQueue.vue'),
},
{
path: 'forges',
component: (): Component => import('~/components/layout/RouteWrapper.vue'),
props: true,
children: [
{
path: '',
name: 'admin-settings-forges',
component: (): Component => import('~/views/admin/forges/AdminForges.vue'),
},
{
path: ':forgeId',
name: 'admin-settings-forge',
component: (): Component => import('~/views/admin/forges/AdminForge.vue'),
props: true,
},
{
path: 'create',
name: 'admin-settings-forge-create',
component: (): Component => import('~/views/admin/forges/AdminForgeCreate.vue'),
},
],
},
],
},

View File

@@ -11,6 +11,7 @@
<Tab icon="org" :to="{ name: 'admin-settings-orgs' }" :title="$t('admin.settings.orgs.orgs')" />
<Tab icon="agent" :to="{ name: 'admin-settings-agents' }" :title="$t('admin.settings.agents.agents')" />
<Tab icon="tray-full" :to="{ name: 'admin-settings-queue' }" :title="$t('admin.settings.queue.queue')" />
<Tab icon="forge" :to="{ name: 'admin-settings-forges' }" :title="$t('forges')" />
<router-view />
</Scaffold>

View File

@@ -0,0 +1,59 @@
<template>
<Settings :title="$t('forges')" :description="$t('forges_desc')">
<template #headerActions>
<Button :text="$t('show_forges')" start-icon="back" :to="{ name: 'admin-settings-forges' }" />
</template>
<AdminForgeForm v-if="forge" v-model:forge="forge" :is-saving="isSaving" @submit="saveForge" />
<div v-else-if="loading" class="flex justify-center">
<Icon name="spinner" class="animate-spin" />
</div>
</Settings>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import AdminForgeForm from '~/components/admin/settings/forges/AdminForgeForm.vue';
import Button from '~/components/atomic/Button.vue';
import Icon from '~/components/atomic/Icon.vue';
import Settings from '~/components/layout/Settings.vue';
import useApiClient from '~/compositions/useApiClient';
import { useAsyncAction } from '~/compositions/useAsyncAction';
import useNotifications from '~/compositions/useNotifications';
import type { Forge } from '~/lib/api/types';
const apiClient = useApiClient();
const notifications = useNotifications();
const { t } = useI18n();
const route = useRoute();
const forgeId = computed(() => Number.parseInt(route.params.forgeId.toString(), 10));
const forge = ref<Forge>();
const loading = ref(false);
async function load() {
loading.value = true;
forge.value = await apiClient.getForge(forgeId.value);
loading.value = false;
}
onMounted(load);
watch(forgeId, load);
const { doSubmit: saveForge, isLoading: isSaving } = useAsyncAction(async () => {
if (!forge.value) {
throw new Error("Unexpected: Can't get forge");
}
await apiClient.updateForge(forge.value);
notifications.notify({
title: t('forge_saved'),
type: 'success',
});
await load(); // reload
});
</script>

View File

@@ -0,0 +1,44 @@
<template>
<Settings :title="$t('forges')" :description="$t('forges_desc')">
<template #headerActions>
<Button :text="$t('show_forges')" start-icon="back" :to="{ name: 'admin-settings-forges' }" />
</template>
<AdminForgeForm v-model:forge="forge" :is-saving="isSaving" is-new @submit="saveForge" />
</Settings>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import AdminForgeForm from '~/components/admin/settings/forges/AdminForgeForm.vue';
import Button from '~/components/atomic/Button.vue';
import Settings from '~/components/layout/Settings.vue';
import useApiClient from '~/compositions/useApiClient';
import { useAsyncAction } from '~/compositions/useAsyncAction';
import useNotifications from '~/compositions/useNotifications';
import type { Forge } from '~/lib/api/types';
const apiClient = useApiClient();
const notifications = useNotifications();
const { t } = useI18n();
const router = useRouter();
const forge = ref<Partial<Forge>>({});
const { doSubmit: saveForge, isLoading: isSaving } = useAsyncAction(async () => {
if (!forge.value) {
throw new Error("Unexpected: Can't get forge");
}
forge.value = await apiClient.createForge(forge.value);
notifications.notify({
title: t('forge_created'),
type: 'success',
});
await router.push({ name: 'admin-settings-forge', params: { forgeId: forge.value.id } });
});
</script>

View File

@@ -0,0 +1,73 @@
<template>
<Settings :title="$t('forges')" :description="$t('forges_desc')">
<template #headerActions>
<Button :text="$t('add_forge')" start-icon="plus" :to="{ name: 'admin-settings-forge-create' }" />
</template>
<div class="text-wp-text-100 space-y-4">
<ListItem
v-for="forge in forges"
:key="forge.id"
class="bg-wp-background-200! dark:bg-wp-background-100! items-center gap-2"
>
<span>{{ forge.url.replace(/http(s):\/\//, '') }}</span>
<Badge class="md:display-unset ml-auto hidden" :value="forge.type" />
<IconButton
icon="edit"
:title="$t('edit_forge')"
class="md:display-unset h-8 w-8"
:to="{ name: 'admin-settings-forge', params: { forgeId: forge.id } }"
/>
<IconButton
icon="trash"
:title="$t('delete_forge')"
class="hover:text-wp-error-100 ml-2 h-8 w-8"
:is-loading="isDeleting"
@click="deleteForge(forge)"
/>
</ListItem>
<div v-if="loading" class="flex justify-center">
<Icon name="spinner" class="animate-spin" />
</div>
<div v-else-if="forges?.length === 0" class="ml-2">{{ $t('no_forges') }}</div>
</div>
</Settings>
</template>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import Badge from '~/components/atomic/Badge.vue';
import Button from '~/components/atomic/Button.vue';
import Icon from '~/components/atomic/Icon.vue';
import IconButton from '~/components/atomic/IconButton.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import Settings from '~/components/layout/Settings.vue';
import useApiClient from '~/compositions/useApiClient';
import { useAsyncAction } from '~/compositions/useAsyncAction';
import useNotifications from '~/compositions/useNotifications';
import { usePagination } from '~/compositions/usePaginate';
import type { Forge } from '~/lib/api/types';
const apiClient = useApiClient();
const notifications = useNotifications();
const { t } = useI18n();
async function loadForges(page: number): Promise<Forge[] | null> {
return apiClient.getForges({ page });
}
const { resetPage, data: forges, loading } = usePagination(loadForges);
const { doSubmit: deleteForge, isLoading: isDeleting } = useAsyncAction(async (_forge: Forge) => {
// eslint-disable-next-line no-alert
if (!confirm(t('forge_delete_confirm'))) {
return;
}
await apiClient.deleteForge(_forge);
notifications.notify({ title: t('forge_deleted'), type: 'success' });
await resetPage();
});
</script>