Add ticket-driven branch creation flow

This commit is contained in:
2026-03-26 09:39:18 +01:00
parent 789675f83f
commit 92dfe3840c
47 changed files with 1332 additions and 50 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
import { Head } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes';
import { dashboard } from '@/routes/index';
import type { BreadcrumbItem } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { dashboard, login, register } from '@/routes';
import { dashboard, login, register } from '@/routes/index';
withDefaults(
defineProps<{
+1 -1
View File
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { login } from '@/routes';
import { login } from '@/routes/index';
import { email } from '@/routes/password';
defineProps<{
+1 -1
View File
@@ -9,7 +9,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthBase from '@/layouts/AuthLayout.vue';
import { register } from '@/routes';
import { register } from '@/routes/index';
import { store } from '@/routes/login';
import { request } from '@/routes/password';
+1 -1
View File
@@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthBase from '@/layouts/AuthLayout.vue';
import { login } from '@/routes';
import { login } from '@/routes/index';
import { store } from '@/routes/register';
</script>
+1 -1
View File
@@ -4,7 +4,7 @@ import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { logout } from '@/routes';
import { logout } from '@/routes/index';
import { send } from '@/routes/verification';
defineProps<{
+1 -1
View File
@@ -12,7 +12,7 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { access, index, show } from '@/routes/projects';
import { access, index, show } from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
const props = defineProps<ProjectAccessPageProps>();
+1 -1
View File
@@ -18,7 +18,7 @@ import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects';
import { index, show } from '@/routes/projects/index';
import { show as showTicket } from '@/routes/projects/tickets';
import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
+1 -1
View File
@@ -13,7 +13,7 @@ import {
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects';
import { index as projectIndex, show } from '@/routes/projects/index';
import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board';
import { show as showTicket } from '@/routes/projects/tickets';
+1 -1
View File
@@ -14,7 +14,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { create, index } from '@/routes/projects';
import { create, index } from '@/routes/projects/index';
import type { BreadcrumbItem } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [
+1 -1
View File
@@ -12,7 +12,7 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { create, index, show } from '@/routes/projects';
import { create, index, show } from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
defineProps<ProjectsIndexPageProps>();
+1 -1
View File
@@ -12,7 +12,7 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { access, index, show } from '@/routes/projects';
import { access, index, show } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import {
+405 -10
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { Form, useForm, Head, Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
@@ -15,14 +17,24 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { edit as editGitConnections } from '@/routes/git-connections';
import { edit as editGitConnections } from '@/routes/git-connections/index';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index as projectIndex, show } from '@/routes/projects';
import { index as projectIndex, show } from '@/routes/projects/index';
import { show as showTicket } from '@/routes/projects/tickets';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>();
@@ -45,6 +57,26 @@ const visibleRepositoryPullRequests = computed(() =>
),
);
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
props.repositoryBranches.map(
(repositoryBranch) => repositoryBranch.repository_connection,
),
);
const canCreateBranches = computed(
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
);
const defaultBranchRepositoryConnectionId = computed(() =>
String(
linkedRepositoryConnections.value.find(
(repositoryConnection) => repositoryConnection.has_personal_access,
)?.id ??
linkedRepositoryConnections.value[0]?.id ??
'',
),
);
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
@@ -87,6 +119,184 @@ const form = useForm({
title: props.ticket.title,
});
const isCreateBranchDialogOpen = ref(false);
const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<{
branch_name: string;
repository_connection_id: string;
}>({
branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value,
});
const selectedBranchRepositoryConnection = computed(
(): RepositoryConnectionModel | null =>
linkedRepositoryConnections.value.find(
(repositoryConnection) =>
String(repositoryConnection.id) ===
branchCreationForm.repository_connection_id,
) ?? null,
);
const selectedBranchRepositoryHasPersonalAccess = computed(
() => selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
);
const initializeBranchCreationForm = (): void => {
branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id =
defaultBranchRepositoryConnectionId.value;
branchNameValidation.value = null;
isCheckingBranchName.value = false;
};
watch(
() => isCreateBranchDialogOpen.value,
(isOpen) => {
if (!isOpen) {
branchCreationForm.clearErrors();
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
initializeBranchCreationForm();
},
);
watch(
[
() => isCreateBranchDialogOpen.value,
() => branchCreationForm.repository_connection_id,
() => branchCreationForm.branch_name,
],
([isOpen, repositoryConnectionId, branchName], _previousValues, onCleanup) => {
if (!isOpen) {
return;
}
const repositoryConnection = selectedBranchRepositoryConnection.value;
if (repositoryConnection === null || repositoryConnectionId === '') {
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
if (!repositoryConnection.has_personal_access) {
branchNameValidation.value = {
is_valid: false,
message:
'Connect a matching Git connection in your user settings first.',
};
isCheckingBranchName.value = false;
return;
}
if (branchName.trim() === '') {
branchNameValidation.value = {
is_valid: false,
message: 'Branch name is required.',
};
isCheckingBranchName.value = false;
return;
}
const abortController = new AbortController();
const timeoutId = window.setTimeout(async () => {
isCheckingBranchName.value = true;
try {
const response = await fetch(
ProjectTicketBranchController.validate.url(
{
project: props.project.id,
ticket: props.ticket.id,
},
{
query: {
branch_name: branchName,
repository_connection_id:
repositoryConnectionId,
},
},
),
{
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
method: 'GET',
signal: abortController.signal,
},
);
if (!response.ok) {
throw new Error('Branch validation failed.');
}
branchNameValidation.value =
(await response.json()) as BranchNameValidationModel;
} catch (error) {
if (
error instanceof DOMException &&
error.name === 'AbortError'
) {
return;
}
branchNameValidation.value = {
is_valid: false,
message:
'KanbanGit could not validate this branch name right now.',
};
} finally {
if (!abortController.signal.aborted) {
isCheckingBranchName.value = false;
}
}
}, 300);
onCleanup(() => {
abortController.abort();
window.clearTimeout(timeoutId);
isCheckingBranchName.value = false;
});
},
{ immediate: true },
);
const submitBranchCreation = (): void => {
if (
!canCreateBranches.value ||
!selectedBranchRepositoryHasPersonalAccess.value ||
branchNameValidation.value?.is_valid !== true
) {
return;
}
branchCreationForm.submit(
ProjectTicketBranchController.store({
project: props.project.id,
ticket: props.ticket.id,
}),
{
onSuccess: () => {
isCreateBranchDialogOpen.value = false;
},
preserveScroll: true,
},
);
};
const submit = (): void => {
if (!props.canEditTicket) {
return;
@@ -448,13 +658,198 @@ const formatDateTime = (value: string): string => {
<div class="space-y-6">
<Card class="h-fit">
<CardHeader>
<CardTitle>Branches</CardTitle>
<CardDescription>
Matching branches are discovered from linked
repositories when the branch name starts with
this ticket key.
</CardDescription>
<CardHeader
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-1.5">
<CardTitle>Branches</CardTitle>
<CardDescription>
Matching branches are discovered from
linked repositories when the branch name
starts with this ticket key.
</CardDescription>
</div>
<Dialog v-model:open="isCreateBranchDialogOpen">
<Button
v-if="canCreateBranches"
type="button"
variant="outline"
class="gap-2"
@click="isCreateBranchDialogOpen = true"
>
<GitBranchPlus class="size-4" />
Create branch
</Button>
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-3">
<DialogTitle>
Create branch from ticket
</DialogTitle>
<DialogDescription>
Pick a linked repository, confirm
the suggested branch name, and
create it directly in Gitea.
</DialogDescription>
</DialogHeader>
<form
class="grid gap-4"
@submit.prevent="submitBranchCreation"
>
<div class="grid gap-2">
<Label for="repository_connection_id">
Repository
</Label>
<select
id="repository_connection_id"
v-model="
branchCreationForm.repository_connection_id
"
:disabled="
branchCreationForm.processing
"
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="repositoryConnection in linkedRepositoryConnections"
:key="repositoryConnection.id"
:value="
String(
repositoryConnection.id,
)
"
>
{{
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
}}
</option>
</select>
<InputError
:message="
branchCreationForm.errors
.repository_connection_id
"
/>
</div>
<div class="grid gap-2">
<Label for="branch_name">
Branch name
</Label>
<Input
id="branch_name"
v-model="
branchCreationForm.branch_name
"
:disabled="
branchCreationForm.processing ||
!selectedBranchRepositoryHasPersonalAccess
"
/>
<InputError
:message="
branchCreationForm.errors
.branch_name
"
/>
</div>
<div
class="rounded-xl border px-4 py-3"
:class="
branchNameValidation?.is_valid
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
"
>
<div
class="flex items-start gap-3"
>
<CheckCircle2
v-if="
branchNameValidation?.is_valid
"
class="mt-0.5 size-4 shrink-0"
/>
<XCircle
v-else-if="
branchNameValidation !==
null
"
class="mt-0.5 size-4 shrink-0"
/>
<p class="text-sm">
{{
isCheckingBranchName
? 'Checking whether this branch name is valid and still available...'
: branchNameValidation?.message ??
'Select a linked repository to validate the branch name.'
}}
</p>
</div>
</div>
<div
v-if="
!selectedBranchRepositoryHasPersonalAccess &&
selectedBranchRepositoryConnection !==
null
"
class="rounded-xl border border-dashed border-border px-4 py-4"
>
<p
class="text-sm text-muted-foreground"
>
You can only create branches in
repositories that match one of
your personal Git connections.
</p>
<Button
as-child
variant="outline"
size="sm"
class="mt-3"
>
<Link
:href="
editGitConnections()
"
>
Open Git connections
</Link>
</Button>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="secondary"
@click="
isCreateBranchDialogOpen =
false
"
>
Cancel
</Button>
<Button
type="submit"
:disabled="
branchCreationForm.processing ||
isCheckingBranchName ||
branchNameValidation?.is_valid !==
true
"
>
Create now
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent class="space-y-4">
@@ -15,7 +15,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects';
import { index, show } from '@/routes/projects/index';
import {
board as boardSettings,
repositories as repositorySettings,
@@ -15,7 +15,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects';
import { index, show } from '@/routes/projects/index';
import {
board as boardSettings,
metadata as metadataSettings,
@@ -15,8 +15,8 @@ import {
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { edit as editGitConnections } from '@/routes/git-connections';
import { index, show } from '@/routes/projects';
import { edit as editGitConnections } from '@/routes/git-connections/index';
import { index, show } from '@/routes/projects/index';
import {
board as boardSettings,
metadata as metadataSettings,
+1 -1
View File
@@ -4,7 +4,7 @@ import AppearanceTabs from '@/components/AppearanceTabs.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/appearance';
import { edit } from '@/routes/appearance/index';
import type { BreadcrumbItem } from '@/types';
const breadcrumbItems: BreadcrumbItem[] = [
@@ -16,7 +16,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit as editGitConnections } from '@/routes/git-connections';
import { edit as editGitConnections } from '@/routes/git-connections/index';
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
defineProps<SettingsGitConnectionsPageProps>();
+1 -1
View File
@@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/profile';
import { edit } from '@/routes/profile/index';
import { send } from '@/routes/verification';
import type { BreadcrumbItem } from '@/types';
+1 -1
View File
@@ -13,7 +13,7 @@ import { Label } from '@/components/ui/label';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/security';
import { edit } from '@/routes/security/index';
import { disable, enable } from '@/routes/two-factor';
import type { BreadcrumbItem } from '@/types';