diff --git a/__tests__/lib/job-status-url.test.ts b/__tests__/lib/job-status-url.test.ts new file mode 100644 index 0000000..ee182c0 --- /dev/null +++ b/__tests__/lib/job-status-url.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { getBranchUrl } from '@/lib/job-status-url' + +describe('getBranchUrl', () => { + it('builds a GitHub tree URL from repo URL and branch', () => { + expect(getBranchUrl('https://github.com/owner/repo', 'feat/job-abc12345')).toBe( + 'https://github.com/owner/repo/tree/feat/job-abc12345', + ) + }) + + it('strips trailing .git suffix', () => { + expect(getBranchUrl('https://github.com/owner/repo.git', 'feat/job-abc')).toBe( + 'https://github.com/owner/repo/tree/feat/job-abc', + ) + }) + + it('strips trailing slash', () => { + expect(getBranchUrl('https://github.com/owner/repo/', 'feat/job-abc')).toBe( + 'https://github.com/owner/repo/tree/feat/job-abc', + ) + }) +}) diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 980bcc0..09f3a29 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -110,6 +110,7 @@ export default async function SoloProductPage({ params }: Props) { unassignedStories={unassignedStories} isDemo={session.isDemo ?? false} currentUserId={session.userId} + repoUrl={product.repo_url} /> ) } diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index 4e93ba8..98643bf 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -45,6 +45,7 @@ type JobPayload = { product_id: string status: string branch?: string + pushed_at?: string summary?: string error?: string } @@ -258,7 +259,7 @@ async function prisma_jobs_findActive(userId: string, productId: string) { ], }, select: { - id: true, task_id: true, status: true, branch: true, summary: true, error: true, + id: true, task_id: true, status: true, branch: true, pushed_at: true, summary: true, error: true, }, orderBy: { created_at: 'asc' }, }) @@ -267,6 +268,7 @@ async function prisma_jobs_findActive(userId: string, productId: string) { task_id: j.task_id, status: jobStatusToApi(j.status), branch: j.branch ?? undefined, + pushed_at: j.pushed_at?.toISOString() ?? undefined, summary: j.summary ?? undefined, error: j.error ?? undefined, })) diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 83d220e..3ff8748 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -38,6 +38,7 @@ export interface SoloBoardProps { unassignedStories: UnassignedStory[] isDemo: boolean currentUserId: string + repoUrl?: string | null } const COLUMN_STATUSES: ColumnStatus[] = ['TO_DO', 'IN_PROGRESS', 'DONE'] @@ -48,7 +49,7 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus { } export function SoloBoard({ - productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, + productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl, }: SoloBoardProps) { const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore() const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId) @@ -219,6 +220,7 @@ export function SoloBoard({ task={selectedTask} productId={productId} isDemo={isDemo} + repoUrl={repoUrl} onClose={() => setSelectedTask(null)} /> diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx index 5757393..d25975c 100644 --- a/components/solo/task-detail-dialog.tsx +++ b/components/solo/task-detail-dialog.tsx @@ -13,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { useSoloStore } from '@/stores/solo-store' import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs' import { cn } from '@/lib/utils' +import { getBranchUrl } from '@/lib/job-status-url' import type { SoloTask } from './solo-board' const STATUS_COLORS: Record = { @@ -33,6 +34,7 @@ interface TaskDetailDialogProps { task: SoloTask | null productId: string isDemo: boolean + repoUrl?: string | null onClose: () => void } @@ -40,12 +42,13 @@ interface TaskDetailContentProps { task: SoloTask productId: string isDemo: boolean + repoUrl?: string | null onClose: () => void } type SaveState = 'idle' | 'saving' | 'saved' -function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailContentProps) { +function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDetailContentProps) { const { updatePlan } = useSoloStore() const job = useSoloStore(s => s.claudeJobsByTaskId[task.id]) const connectedWorkers = useSoloStore(s => s.connectedWorkers) @@ -206,8 +209,18 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte )} {job?.status === 'done' && ( - - Klaar{job.branch ? ` — branch ${job.branch}` : ''} + + Klaar{job.branch && !job.pushed_at ? ` — branch ${job.branch}` : ''} + {job.pushed_at && job.branch && repoUrl && ( + + Open op GitHub + + )} )} @@ -219,7 +232,7 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte ) } -export function TaskDetailDialog({ task, productId, isDemo, onClose }: TaskDetailDialogProps) { +export function TaskDetailDialog({ task, productId, isDemo, repoUrl, onClose }: TaskDetailDialogProps) { return ( { if (!open) onClose() }}> @@ -229,6 +242,7 @@ export function TaskDetailDialog({ task, productId, isDemo, onClose }: TaskDetai task={task} productId={productId} isDemo={isDemo} + repoUrl={repoUrl} onClose={onClose} /> )} diff --git a/lib/job-status-url.ts b/lib/job-status-url.ts new file mode 100644 index 0000000..828f169 --- /dev/null +++ b/lib/job-status-url.ts @@ -0,0 +1,4 @@ +export function getBranchUrl(repoUrl: string, branch: string): string { + const base = repoUrl.replace(/\.git$/, '').replace(/\/$/, '') + return `${base}/tree/${branch}` +} diff --git a/stores/solo-store.ts b/stores/solo-store.ts index 46cf569..75db95d 100644 --- a/stores/solo-store.ts +++ b/stores/solo-store.ts @@ -9,13 +9,14 @@ export interface JobState { task_id: string status: ClaudeJobStatusApi branch?: string + pushed_at?: string | null summary?: string error?: string } export type ClaudeJobEvent = | { type: 'claude_job_enqueued'; job_id: string; task_id: string; user_id: string; product_id: string; status: 'queued' } - | { type: 'claude_job_status'; job_id: string; task_id: string; user_id: string; product_id: string; status: ClaudeJobStatusApi; branch?: string; summary?: string; error?: string } + | { type: 'claude_job_status'; job_id: string; task_id: string; user_id: string; product_id: string; status: ClaudeJobStatusApi; branch?: string; pushed_at?: string; summary?: string; error?: string } // Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801 // + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit @@ -146,7 +147,7 @@ export const useSoloStore = create((set, get) => ({ return } if (event.type === 'claude_job_status') { - const { status, branch, summary, error } = event + const { status, branch, pushed_at, summary, error } = event if (status === 'cancelled') { set((s) => { const next = { ...s.claudeJobsByTaskId } @@ -158,7 +159,7 @@ export const useSoloStore = create((set, get) => ({ set((s) => ({ claudeJobsByTaskId: { ...s.claudeJobsByTaskId, - [task_id]: { job_id, task_id, status, branch, summary, error }, + [task_id]: { job_id, task_id, status, branch, pushed_at, summary, error }, }, })) }