feat(M13): UI — 'Open PR' link on DONE-card; pr_url in JobState + SSE + task-dialog
This commit is contained in:
parent
12a58e8b45
commit
f08574357b
4 changed files with 66 additions and 5 deletions
|
|
@ -132,6 +132,54 @@ describe('TaskDetailDialog — verify_result display', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('TaskDetailDialog — PR link display', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSoloStore.setState({
|
||||
tasks: { 'task-1': baseTask },
|
||||
claudeJobsByTaskId: {},
|
||||
connectedWorkers: 0,
|
||||
})
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
||||
})
|
||||
|
||||
it('shows "Open PR" link when pr_url is set', () => {
|
||||
useSoloStore.setState({
|
||||
claudeJobsByTaskId: {
|
||||
'task-1': {
|
||||
job_id: 'j1',
|
||||
task_id: 'task-1',
|
||||
status: 'done',
|
||||
branch: 'feat/job-abc',
|
||||
pushed_at: '2026-01-01T00:00:00Z',
|
||||
pr_url: 'https://github.com/org/repo/pull/42',
|
||||
},
|
||||
},
|
||||
})
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
||||
const link = screen.getByRole('link', { name: /Open PR/i })
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/org/repo/pull/42')
|
||||
})
|
||||
|
||||
it('shows "Open op GitHub" branch link when pushed_at is set but no pr_url', () => {
|
||||
useSoloStore.setState({
|
||||
claudeJobsByTaskId: {
|
||||
'task-1': {
|
||||
job_id: 'j1',
|
||||
task_id: 'task-1',
|
||||
status: 'done',
|
||||
branch: 'feat/job-abc',
|
||||
pushed_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
||||
expect(screen.queryByText(/Open PR/)).toBeNull()
|
||||
const link = screen.getByRole('link', { name: /Open op GitHub/i })
|
||||
expect(link).toHaveAttribute('href', expect.stringContaining('feat/job-abc'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskDetailDialog — verify_only checkbox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ type JobPayload = {
|
|||
status: string
|
||||
branch?: string
|
||||
pushed_at?: string
|
||||
pr_url?: string
|
||||
verify_result?: string
|
||||
summary?: string
|
||||
error?: string
|
||||
|
|
@ -260,7 +261,7 @@ async function prisma_jobs_findActive(userId: string, productId: string) {
|
|||
],
|
||||
},
|
||||
select: {
|
||||
id: true, task_id: true, status: true, branch: true, pushed_at: true, verify_result: true, summary: true, error: true,
|
||||
id: true, task_id: true, status: true, branch: true, pushed_at: true, pr_url: true, verify_result: true, summary: true, error: true,
|
||||
},
|
||||
orderBy: { created_at: 'asc' },
|
||||
})
|
||||
|
|
@ -270,6 +271,7 @@ async function prisma_jobs_findActive(userId: string, productId: string) {
|
|||
status: jobStatusToApi(j.status),
|
||||
branch: j.branch ?? undefined,
|
||||
pushed_at: j.pushed_at?.toISOString() ?? undefined,
|
||||
pr_url: j.pr_url ?? undefined,
|
||||
verify_result: j.verify_result?.toLowerCase() as import('@/stores/solo-store').VerifyResultApi | undefined,
|
||||
summary: j.summary ?? undefined,
|
||||
error: j.error ?? undefined,
|
||||
|
|
|
|||
|
|
@ -269,7 +269,17 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe
|
|||
{job?.status === 'done' && (
|
||||
<span className="text-xs text-status-done flex items-center gap-2 flex-wrap">
|
||||
Klaar{job.branch && !job.pushed_at ? ` — branch ${job.branch}` : ''}
|
||||
{job.pushed_at && job.branch && repoUrl && (
|
||||
{job.pr_url && (
|
||||
<a
|
||||
href={job.pr_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary hover:text-primary/80"
|
||||
>
|
||||
Open PR
|
||||
</a>
|
||||
)}
|
||||
{!job.pr_url && job.pushed_at && job.branch && repoUrl && (
|
||||
<a
|
||||
href={getBranchUrl(repoUrl, job.branch)}
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface JobState {
|
|||
status: ClaudeJobStatusApi
|
||||
branch?: string
|
||||
pushed_at?: string | null
|
||||
pr_url?: string | null
|
||||
verify_result?: VerifyResultApi | null
|
||||
summary?: string
|
||||
error?: string
|
||||
|
|
@ -19,7 +20,7 @@ export interface JobState {
|
|||
|
||||
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; pushed_at?: string; verify_result?: VerifyResultApi; 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; pr_url?: string; verify_result?: VerifyResultApi; 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
|
||||
|
|
@ -154,7 +155,7 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
|||
return
|
||||
}
|
||||
if (event.type === 'claude_job_status') {
|
||||
const { status, branch, pushed_at, verify_result, summary, error } = event
|
||||
const { status, branch, pushed_at, pr_url, verify_result, summary, error } = event
|
||||
if (status === 'cancelled') {
|
||||
set((s) => {
|
||||
const next = { ...s.claudeJobsByTaskId }
|
||||
|
|
@ -166,7 +167,7 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
|||
set((s) => ({
|
||||
claudeJobsByTaskId: {
|
||||
...s.claudeJobsByTaskId,
|
||||
[task_id]: { job_id, task_id, status, branch, pushed_at, verify_result, summary, error },
|
||||
[task_id]: { job_id, task_id, status, branch, pushed_at, pr_url, verify_result, summary, error },
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue