feat : add TaskGitSection component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-13 14:03:51 +01:00
parent 027e31e139
commit 6eeacd2cb0

View File

@@ -0,0 +1,280 @@
<template>
<div class="mt-5 rounded-lg border border-neutral-200 bg-neutral-50 p-4">
<h3 class="text-sm font-bold text-neutral-900">{{ $t('gitea.branch.title') }}</h3>
<!-- Error state -->
<p v-if="error" class="mt-2 text-sm text-red-500">{{ $t('gitea.error') }}</p>
<!-- Create branch form -->
<div v-if="!showCreateForm" class="mt-3 flex gap-2">
<button
type="button"
class="rounded-md bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-secondary-500"
@click="showCreateForm = true"
>
{{ $t('gitea.branch.create') }}
</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-semibold text-neutral-600 hover:bg-neutral-100"
@click="handleCopy"
>
{{ $t('gitea.branch.copy') }}
</button>
</div>
<div v-if="showCreateForm" class="mt-3 space-y-3 rounded-md border border-neutral-200 bg-white p-3">
<div class="grid grid-cols-2 gap-3">
<MalioSelect
v-model="branchForm.type"
:options="typeOptions"
:label="$t('gitea.branch.type')"
min-width="w-full"
/>
<MalioInputText
v-model="branchForm.baseBranch"
:label="$t('gitea.branch.baseBranch')"
input-class="w-full"
/>
</div>
<div>
<p class="text-xs text-neutral-500">{{ $t('gitea.branch.preview') }}</p>
<code class="mt-1 block rounded bg-neutral-100 px-2 py-1 text-xs text-neutral-800">
{{ branchPreview }}
</code>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded-md bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
:disabled="isCreating"
@click="handleCreate"
>
{{ $t('gitea.branch.create') }}
</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-semibold text-neutral-600 hover:bg-neutral-100"
@click="showCreateForm = false"
>
{{ $t('common.cancel') }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="isLoading" class="mt-3 text-xs text-neutral-400">{{ $t('common.loading') }}</div>
<!-- Branches list -->
<div v-if="!isLoading && branches.length" class="mt-4 space-y-3">
<div v-for="branch in branches" :key="branch.name" class="rounded-md border border-neutral-200 bg-white p-3">
<div class="flex items-center gap-2">
<Icon name="mdi:source-branch" size="16" class="text-primary-500" />
<a
:href="branchUrl(branch.name)"
target="_blank"
class="text-sm font-medium text-primary-500 hover:underline"
>
{{ branch.name }}
</a>
</div>
<!-- Commits -->
<div v-if="branch.commits.length" class="ml-6 mt-2 space-y-1">
<p class="text-xs font-semibold text-neutral-500">{{ $t('gitea.branch.commits') }}</p>
<div
v-for="commit in branch.commits.slice(0, 5)"
:key="commit.sha"
class="flex items-baseline gap-2 text-xs"
>
<code class="text-primary-500">{{ commit.sha }}</code>
<span class="truncate text-neutral-700">{{ commitFirstLine(commit.message) }}</span>
<span class="whitespace-nowrap text-neutral-400">{{ commit.author }}</span>
</div>
</div>
</div>
</div>
<p v-if="!isLoading && !branches.length && !error" class="mt-3 text-xs text-neutral-400">
{{ $t('gitea.branch.noBranches') }}
</p>
<!-- Pull Requests -->
<div v-if="!isLoadingPrs && pullRequests.length" class="mt-4">
<h4 class="text-xs font-bold text-neutral-700">{{ $t('gitea.pr.title') }}</h4>
<div class="mt-2 space-y-2">
<div v-for="pr in pullRequests" :key="pr.number" class="rounded-md border border-neutral-200 bg-white p-3">
<div class="flex items-center gap-2">
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:class="prStatusClass(pr)"
>
{{ prStatusLabel(pr) }}
</span>
<a
:href="pr.url"
target="_blank"
class="text-sm font-medium text-primary-500 hover:underline"
>
#{{ pr.number }} {{ pr.title }}
</a>
<span class="text-xs text-neutral-400">{{ pr.author }}</span>
</div>
<!-- CI statuses -->
<div v-if="pr.ciStatuses.length" class="ml-6 mt-2 flex flex-wrap gap-2">
<a
v-for="ci in pr.ciStatuses"
:key="ci.context"
:href="ci.target_url"
target="_blank"
class="flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
:class="ciStatusClass(ci.status)"
>
<Icon :name="ciStatusIcon(ci.status)" size="12" />
{{ ci.context }}
</a>
</div>
</div>
</div>
</div>
<p v-if="!isLoadingPrs && !pullRequests.length && branches.length && !error" class="mt-3 text-xs text-neutral-400">
{{ $t('gitea.pr.noPrs') }}
</p>
</div>
</template>
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
import { useGiteaService } from '~/services/gitea'
const { t } = useI18n()
const props = defineProps<{
task: Task
giteaUrl: string
}>()
const { listBranches, createBranch, listPullRequests, getBranchName } = useGiteaService()
const branches = ref<GiteaBranch[]>([])
const pullRequests = ref<GiteaPullRequest[]>([])
const isLoading = ref(true)
const isLoadingPrs = ref(true)
const isCreating = ref(false)
const error = ref(false)
const showCreateForm = ref(false)
const branchForm = reactive({
type: 'feature',
baseBranch: 'develop',
})
const typeOptions = [
{ label: t('gitea.branch.types.feature'), value: 'feature' },
{ label: t('gitea.branch.types.fix'), value: 'fix' },
{ label: t('gitea.branch.types.refactor'), value: 'refactor' },
{ label: t('gitea.branch.types.hotfix'), value: 'hotfix' },
{ label: t('gitea.branch.types.chore'), value: 'chore' },
]
const branchPreview = computed(() => {
if (!props.task.project?.code || !props.task.number) return ''
const slug = props.task.title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50)
return `${branchForm.type}/${props.task.project.code}-${props.task.number}-${slug}`
})
function branchUrl(name: string): string {
const project = props.task.project
if (!project?.giteaOwner || !project?.giteaRepo) return '#'
return `${props.giteaUrl}/${project.giteaOwner}/${project.giteaRepo}/src/branch/${encodeURIComponent(name)}`
}
function commitFirstLine(message: string): string {
return message.split('\n')[0]
}
function prStatusClass(pr: GiteaPullRequest): string {
if (pr.merged) return 'bg-purple-500'
if (pr.state === 'open') return 'bg-green-500'
return 'bg-red-500'
}
function prStatusLabel(pr: GiteaPullRequest): string {
if (pr.merged) return t('gitea.pr.merged')
if (pr.state === 'open') return t('gitea.pr.open')
return t('gitea.pr.closed')
}
function ciStatusClass(status: string): string {
if (status === 'success') return 'bg-green-100 text-green-700'
if (status === 'failure' || status === 'error') return 'bg-red-100 text-red-700'
return 'bg-yellow-100 text-yellow-700'
}
function ciStatusIcon(status: string): string {
if (status === 'success') return 'mdi:check-circle'
if (status === 'failure' || status === 'error') return 'mdi:close-circle'
return 'mdi:clock-outline'
}
async function loadData() {
if (!props.task.id) return
isLoading.value = true
isLoadingPrs.value = true
error.value = false
try {
branches.value = await listBranches(props.task.id)
} catch {
error.value = true
} finally {
isLoading.value = false
}
try {
pullRequests.value = await listPullRequests(props.task.id)
} catch {
// PR errors don't block branch display
} finally {
isLoadingPrs.value = false
}
}
async function handleCreate() {
isCreating.value = true
try {
await createBranch(props.task.id, {
type: branchForm.type,
baseBranch: branchForm.baseBranch,
})
showCreateForm.value = false
await loadData()
} finally {
isCreating.value = false
}
}
async function handleCopy() {
try {
const result = await getBranchName(props.task.id, branchForm.type)
await navigator.clipboard.writeText(result.name)
const { success } = useToast()
success(t('gitea.branch.copied'))
} catch {
// Silently fail
}
}
onMounted(() => {
loadData()
})
</script>