From 5e327907058a6c046630e0baf8760c64b5f2998a Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 19:13:26 +0000 Subject: [PATCH 1/2] feat(task T02): implement via codex --- .../src/v2/components/ui/CiStatusBadge.tsx | 148 +++++++ .../src/v2/lib/ci-status-presentation.ts | 414 ++++++++++++++++++ .../v2/components/ui/CiStatusBadge.test.tsx | 77 ++++ .../v2/lib/ci-status-presentation.test.ts | 193 ++++++++ 4 files changed, 832 insertions(+) create mode 100644 dashboard/src/v2/components/ui/CiStatusBadge.tsx create mode 100644 dashboard/src/v2/lib/ci-status-presentation.ts create mode 100644 tests/dashboard/v2/components/ui/CiStatusBadge.test.tsx create mode 100644 tests/dashboard/v2/lib/ci-status-presentation.test.ts diff --git a/dashboard/src/v2/components/ui/CiStatusBadge.tsx b/dashboard/src/v2/components/ui/CiStatusBadge.tsx new file mode 100644 index 0000000000..bdfb163ff7 --- /dev/null +++ b/dashboard/src/v2/components/ui/CiStatusBadge.tsx @@ -0,0 +1,148 @@ +import type { FunctionComponent } from "preact"; +import { useId, useRef, useState } from "preact/hooks"; +import { CheckCircle2, ChevronDown, Clock3, Loader2, XCircle } from "lucide-preact"; +import type { LucideIcon } from "lucide-preact"; +import type { + CiStatusPresentation, + CiWorkflowState, +} from "../../lib/ci-status-presentation.js"; + +export interface CiStatusBadgeProps { + presentation: CiStatusPresentation | null; + compact?: boolean; + className?: string; +} + +const STATE_PRESENTATION = { + pending: { + icon: Clock3, + label: "Pending", + tone: "border-slate-300/70 bg-slate-100/80 text-slate-600 dark:border-white/10 dark:bg-white/[0.05] dark:text-slate-300", + }, + in_progress: { + icon: Loader2, + label: "In progress", + tone: "border-signal-500/25 bg-signal-500/10 text-signal-600 dark:text-signal-400", + }, + successful: { + icon: CheckCircle2, + label: "Successful", + tone: "border-status-green/25 bg-status-green/10 text-status-green", + }, + failed: { + icon: XCircle, + label: "Failed", + tone: "border-status-red/30 bg-status-red/10 text-status-red shadow-[0_8px_22px_rgba(227,0,15,0.10)]", + }, +} satisfies Record; + +export const CiStatusBadge: FunctionComponent = ({ + presentation, + compact = false, + className = "", +}) => { + const [open, setOpen] = useState(false); + const detailsId = useId(); + const triggerRef = useRef(null); + + if (!presentation) return null; + + const meta = STATE_PRESENTATION[presentation.state]; + const StatusIcon = meta.icon; + const toggleDetails = (): void => setOpen((current) => !current); + const closeDetails = (): void => { + setOpen(false); + triggerRef.current?.focus(); + }; + + return ( + + + + {open && ( + { + if (event.key === "Escape") { + event.preventDefault(); + closeDetails(); + } + }} + className="absolute left-0 top-full z-50 mt-2 block w-[min(19rem,calc(100vw-2rem))] rounded-2xl border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] p-3 shadow-[var(--elevation-floating)] backdrop-blur-xl" + > + + Workflow details + + + {presentation.steps.map((step) => { + const stepMeta = STATE_PRESENTATION[step.state]; + const StepIcon = stepMeta.icon; + return ( + + + {step.label} + + {step.statusLabel} + ({stepMeta.label}) + + + ); + })} + + + )} + + ); +}; diff --git a/dashboard/src/v2/lib/ci-status-presentation.ts b/dashboard/src/v2/lib/ci-status-presentation.ts new file mode 100644 index 0000000000..68a7453c6d --- /dev/null +++ b/dashboard/src/v2/lib/ci-status-presentation.ts @@ -0,0 +1,414 @@ +import type { + ExecutionAttentionItemSummary, + ExecutionRuntimeEventSummary, + Subtask, + SubtaskMergeIndicator, +} from "../../../../src/contracts/app-types.js"; + +export type CiWorkflowState = "pending" | "in_progress" | "successful" | "failed"; +export type CiWorkflowStepId = "pull_request" | "checks" | "merge"; +export type CiWorkflowFailureKind = "ci_checks" | "merge_conflict" | "merge_attempt"; + +export interface CiWorkflowStep { + id: CiWorkflowStepId; + label: string; + state: CiWorkflowState; + statusLabel: string; + failureKind?: CiWorkflowFailureKind; +} + +export interface CiStatusPresentation { + scope: "task" | "sprint"; + state: CiWorkflowState; + label: string; + accessibleLabel: string; + steps: [CiWorkflowStep, CiWorkflowStep, CiWorkflowStep]; + failureKind?: CiWorkflowFailureKind; +} + +export type CiTaskMergeEvidence = Pick< + Subtask, + "record_id" | "id" | "sprint_id" | "merge_indicator" | "is_merged" | "pr_url" +>; + +export interface TaskCiStatusPresentationInput { + task: CiTaskMergeEvidence; + events?: readonly ExecutionRuntimeEventSummary[]; + attentionItems?: readonly ExecutionAttentionItemSummary[]; + sprintRunId?: string | null; +} + +export interface SprintCiStatusPresentationInput { + sprintId: string; + sprintRunId?: string | null; + events?: readonly ExecutionRuntimeEventSummary[]; + attentionItems?: readonly ExecutionAttentionItemSummary[]; + tasks?: readonly CiTaskMergeEvidence[]; +} + +interface StepStates { + pullRequest: CiWorkflowState; + checks: CiWorkflowState; + merge: CiWorkflowState; + pullRequestLabel?: string; + checksLabel?: string; + mergeLabel?: string; + checksFailureKind?: CiWorkflowFailureKind; + mergeFailureKind?: CiWorkflowFailureKind; +} + +const ACTIVE_ATTENTION_STATUSES = new Set(["open", "claimed"]); +const STEP_LABELS: Record = { + pull_request: "Pull request", + checks: "Checks", + merge: "Merge", +}; + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function booleanValue(value: unknown): boolean { + return value === true; +} + +function checksStateFromPayload(payload: Record, fallback: CiWorkflowState): CiWorkflowState { + if (payload.hasFailedChecks === true) return "failed"; + if (payload.hasPendingChecks === true) return "in_progress"; + if (payload.hasFailedChecks === false && payload.hasPendingChecks === false) return "successful"; + return fallback; +} + +function eventTimestamp(event: ExecutionRuntimeEventSummary): number { + const timestamp = Date.parse(event.createdAt); + return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY; +} + +function isNewerEvent(candidate: ExecutionRuntimeEventSummary, current: ExecutionRuntimeEventSummary): boolean { + const candidateTime = eventTimestamp(candidate); + const currentTime = eventTimestamp(current); + return candidateTime > currentTime || (candidateTime === currentTime && candidate.id.localeCompare(current.id) > 0); +} + +function eventEntityKey(event: ExecutionRuntimeEventSummary): string { + if (event.eventType === "main_merge_gate_status") return "main_merge"; + return stringValue(event.taskId) + ?? stringValue(event.taskKey) + ?? stringValue(event.payload?.taskId) + ?? stringValue(event.taskRunId) + ?? `event:${event.id}`; +} + +function newestEventsByEntity(events: readonly ExecutionRuntimeEventSummary[]): Map { + const newest = new Map(); + for (const event of events) { + if (event.eventType !== "ci_gate_status" && event.eventType !== "main_merge_gate_status") continue; + const key = eventEntityKey(event); + const current = newest.get(key); + if (!current || isNewerEvent(event, current)) newest.set(key, event); + } + return newest; +} + +function pendingPullRequest(label = "Waiting for pull request"): StepStates { + return { + pullRequest: "pending", + checks: "pending", + merge: "pending", + pullRequestLabel: label, + checksLabel: "Waiting for pull request", + mergeLabel: "Waiting for checks", + }; +} + +function completedWorkflow(mergeLabel = "Merged"): StepStates { + return { + pullRequest: "successful", + checks: "successful", + merge: "successful", + pullRequestLabel: "Pull request ready", + checksLabel: "Checks passed", + mergeLabel, + }; +} + +function normalizeGateEvent(event: ExecutionRuntimeEventSummary): StepStates | null { + const payload = event.payload; + const state = stringValue(payload?.state)?.toLowerCase(); + if (!payload || !state) return null; + + const hasPr = payload.prNumber != null || Boolean(stringValue(payload.prUrl)) || Boolean(event.prUrl); + const pullRequest: CiWorkflowState = hasPr ? "successful" : "pending"; + + if (event.eventType === "main_merge_gate_status") { + switch (state) { + case "disabled": + case "unavailable": + return null; + case "missing_pr": + return pendingPullRequest("Pull request missing"); + case "merged": + case "automerge_succeeded": + return completedWorkflow(); + case "failed_checks": + return { pullRequest: "successful", checks: "failed", merge: "pending", checksLabel: "Checks failed", mergeLabel: "Blocked by checks", checksFailureKind: "ci_checks" }; + case "pending_checks": + return { pullRequest: "successful", checks: "in_progress", merge: "pending", checksLabel: "Checks running", mergeLabel: "Waiting for checks" }; + case "merge_conflict": { + const checks = checksStateFromPayload(payload, "pending"); + return { + pullRequest: "successful", + checks, + merge: "failed", + checksLabel: checks === "failed" ? "Checks failed" : checks === "in_progress" ? "Checks running" : checks === "successful" ? "Checks passed" : "Checks pending", + mergeLabel: "Merge conflict", + ...(checks === "failed" ? { checksFailureKind: "ci_checks" as const } : {}), + mergeFailureKind: "merge_conflict", + }; + } + case "review_blocked": + return { pullRequest: "successful", checks: "successful", merge: "pending", checksLabel: "Checks passed", mergeLabel: "Waiting for review" }; + case "ready_for_merge": + return { pullRequest: "successful", checks: "successful", merge: "pending", checksLabel: "Checks passed", mergeLabel: "Ready to merge" }; + case "automerge_scheduled": { + const checks = checksStateFromPayload(payload, "pending"); + return { pullRequest: "successful", checks, merge: "in_progress", checksLabel: checks === "failed" ? "Checks failed" : checks === "in_progress" ? "Checks running" : checks === "successful" ? "Checks passed" : "Checks pending", mergeLabel: "Merge running", ...(checks === "failed" ? { checksFailureKind: "ci_checks" as const } : {}) }; + } + case "automerge_failed": { + const checks = checksStateFromPayload(payload, "pending"); + return { + pullRequest: "successful", + checks, + merge: "failed", + checksLabel: checks === "failed" ? "Checks failed" : checks === "in_progress" ? "Checks running" : checks === "successful" ? "Checks passed" : "Checks pending", + mergeLabel: "Merge failed", + ...(checks === "failed" ? { checksFailureKind: "ci_checks" as const } : {}), + mergeFailureKind: "merge_attempt", + }; + } + default: + return null; + } + } + + switch (state) { + case "waiting_for_pr": + case "awaiting_merge_no_pr": + return pendingPullRequest(state === "waiting_for_pr" ? "Waiting for pull request" : "Pull request missing"); + case "no_merge_work": + return completedWorkflow("No merge needed"); + case "merged_branch": + case "merge_confirmed": + case "automerge_succeeded": + return completedWorkflow(); + case "pr_created_no_merge": + return completedWorkflow("Merge not required"); + case "ready_for_merge": + return { pullRequest, checks: "successful", merge: "pending", checksLabel: "Checks passed", mergeLabel: "Ready to merge" }; + case "automerge_scheduled": + return { pullRequest, checks: "pending", merge: "in_progress", checksLabel: "Checks pending", mergeLabel: "Merge running" }; + case "automerge_failed": + return { pullRequest, checks: "pending", merge: "failed", checksLabel: "Checks pending", mergeLabel: "Merge failed", mergeFailureKind: "merge_attempt" }; + case "automerge_conflict": + case "merge_conflict": + return { pullRequest, checks: "pending", merge: "failed", checksLabel: "Checks pending", mergeLabel: "Merge conflict", mergeFailureKind: "merge_conflict" }; + case "merge_conflict_pending": + case "merge_conflict_cleared": + return { pullRequest, checks: "pending", merge: "in_progress", checksLabel: "Checks pending", mergeLabel: "Checking mergeability" }; + case "qa_blocked": + return { pullRequest, checks: "pending", merge: "pending", checksLabel: "Checks pending", mergeLabel: "Waiting for QA" }; + case "waiting_checks": + case "blocked": { + if (booleanValue(payload.hasFailedChecks)) { + return { pullRequest, checks: "failed", merge: "pending", checksLabel: "Checks failed", mergeLabel: "Blocked by checks", checksFailureKind: "ci_checks" }; + } + if (booleanValue(payload.hasPendingChecks)) { + return { pullRequest, checks: "in_progress", merge: "pending", checksLabel: "Checks running", mergeLabel: "Waiting for checks" }; + } + if (booleanValue(payload.hasReviewBlockers)) { + return { pullRequest, checks: "successful", merge: "pending", checksLabel: "Checks passed", mergeLabel: "Waiting for review" }; + } + if (payload.hasFailedChecks === false && payload.hasPendingChecks === false) { + return { pullRequest, checks: "successful", merge: "pending", checksLabel: "Checks passed", mergeLabel: "Ready to merge" }; + } + return { pullRequest, checks: "pending", merge: "pending", checksLabel: "Checks pending", mergeLabel: "Waiting for checks" }; + } + default: + return null; + } +} + +function normalizeMergeIndicator(evidence: CiTaskMergeEvidence): StepStates | null { + if (evidence.is_merged) return completedWorkflow(); + const indicator: SubtaskMergeIndicator | undefined = evidence.merge_indicator; + const pullRequest: CiWorkflowState = evidence.pr_url ? "successful" : "pending"; + switch (indicator) { + case "CI": + return { pullRequest, checks: evidence.pr_url ? "in_progress" : "pending", merge: "pending", checksLabel: evidence.pr_url ? "Checks running" : "Waiting for pull request", mergeLabel: "Waiting for checks" }; + case "MERGED": + case "AUTOMERGE": + return completedWorkflow(); + case "PR_ONLY": + return completedWorkflow("Merge not required"); + case "MERGE_CONFLICT": + return { pullRequest, checks: "pending", merge: "failed", checksLabel: "Checks pending", mergeLabel: "Merge conflict", mergeFailureKind: "merge_conflict" }; + case "MERGE_BLOCKED": + return { pullRequest, checks: "pending", merge: "pending", checksLabel: "Checks pending", mergeLabel: "Merge blocked" }; + case "QA_PENDING": + return { pullRequest, checks: "pending", merge: "pending", checksLabel: "Checks pending", mergeLabel: "Waiting for QA" }; + default: + return null; + } +} + +function activeCiAttention(items: readonly ExecutionAttentionItemSummary[]): ExecutionAttentionItemSummary[] { + return items.filter((item) => item.attentionType.toLowerCase() === "ci_fix_required" && ACTIVE_ATTENTION_STATUSES.has(item.status.toLowerCase())); +} + +function withCiAttentionFailure(states: StepStates | null, attention: ExecutionAttentionItemSummary): StepStates { + const hasPr = attention.payload?.prNumber != null || Boolean(stringValue(attention.payload?.prUrl)); + return { + pullRequest: states?.pullRequest ?? (hasPr ? "successful" : "pending"), + checks: "failed", + merge: states?.merge ?? "pending", + pullRequestLabel: states?.pullRequestLabel, + checksLabel: "Checks failed", + mergeLabel: states?.mergeLabel ?? "Blocked by checks", + checksFailureKind: "ci_checks", + mergeFailureKind: states?.mergeFailureKind, + }; +} + +function aggregateState(states: readonly CiWorkflowState[]): CiWorkflowState { + if (states.includes("failed")) return "failed"; + if (states.includes("in_progress")) return "in_progress"; + if (states.includes("pending")) return "pending"; + return "successful"; +} + +function buildPresentation(scope: "task" | "sprint", entities: readonly StepStates[]): CiStatusPresentation | null { + if (entities.length === 0) return null; + const stepData = [ + { id: "pull_request" as const, field: "pullRequest" as const, labelField: "pullRequestLabel" as const, failureField: null }, + { id: "checks" as const, field: "checks" as const, labelField: "checksLabel" as const, failureField: "checksFailureKind" as const }, + { id: "merge" as const, field: "merge" as const, labelField: "mergeLabel" as const, failureField: "mergeFailureKind" as const }, + ]; + const steps = stepData.map(({ id, field, labelField, failureField }): CiWorkflowStep => { + const state = aggregateState(entities.map((entity) => entity[field])); + const matchingEntity = entities.find((entity) => entity[field] === state && entity[labelField]); + const failureKind = failureField && state === "failed" ? matchingEntity?.[failureField] : undefined; + return { + id, + label: STEP_LABELS[id], + state, + statusLabel: matchingEntity?.[labelField] ?? state.replace("_", " "), + ...(failureKind ? { failureKind } : {}), + }; + }) as CiStatusPresentation["steps"]; + const state = aggregateState(steps.map((step) => step.state)); + const failedStep = steps.find((step) => step.state === "failed"); + const activeStep = steps.find((step) => step.state === "in_progress"); + const failureKind = failedStep?.failureKind; + const label = failureKind === "ci_checks" + ? "CI failed" + : failureKind === "merge_conflict" + ? "Merge conflict" + : failureKind === "merge_attempt" + ? "Merge failed" + : state === "in_progress" + ? activeStep?.id === "merge" ? "Merge running" : "CI running" + : state === "successful" ? "CI passed" : "CI pending"; + return { + scope, + state, + label, + accessibleLabel: `${label}. ${steps.map((step) => `${step.label}: ${step.statusLabel}`).join(". ")}.`, + steps, + ...(failureKind ? { failureKind } : {}), + }; +} + +function taskEventMatches(event: ExecutionRuntimeEventSummary, task: CiTaskMergeEvidence, sprintRunId?: string | null): boolean { + if (event.eventType !== "ci_gate_status") return false; + if (task.sprint_id && event.sprintId !== task.sprint_id) return false; + if (sprintRunId && event.sprintRunId !== sprintRunId) return false; + const payloadTaskId = stringValue(event.payload?.taskId); + return Boolean( + (task.record_id && event.taskId === task.record_id) + || event.taskKey === task.id + || payloadTaskId === task.id + || (task.record_id && payloadTaskId === task.record_id), + ); +} + +function taskAttentionMatches(item: ExecutionAttentionItemSummary, task: CiTaskMergeEvidence, sprintRunId?: string | null): boolean { + if (task.sprint_id && item.sprintId !== task.sprint_id) return false; + if (sprintRunId && item.sprintRunId !== sprintRunId) return false; + const payloadTask = stringValue(item.payload?.taskId) ?? stringValue(item.payload?.taskKey); + return Boolean((task.record_id && item.taskId === task.record_id) || payloadTask === task.id || (task.record_id && payloadTask === task.record_id)); +} + +export function deriveTaskCiStatusPresentation(input: TaskCiStatusPresentationInput): CiStatusPresentation | null { + const matchingEvents = (input.events ?? []).filter((event) => taskEventMatches(event, input.task, input.sprintRunId)); + const latestEvent = [...newestEventsByEntity(matchingEvents).values()].reduce( + (latest, event) => !latest || isNewerEvent(event, latest) ? event : latest, + null, + ); + let states = latestEvent ? normalizeGateEvent(latestEvent) : normalizeMergeIndicator(input.task); + const attention = activeCiAttention(input.attentionItems ?? []).find((item) => taskAttentionMatches(item, input.task, input.sprintRunId)); + if (attention) states = withCiAttentionFailure(states, attention); + return buildPresentation("task", states ? [states] : []); +} + +export function deriveSprintCiStatusPresentation(input: SprintCiStatusPresentationInput): CiStatusPresentation | null { + const scopedEvents = (input.events ?? []).filter((event) => ( + event.sprintId === input.sprintId + && (!input.sprintRunId || event.sprintRunId === input.sprintRunId) + )); + const taskAliases = new Map(); + for (const task of input.tasks ?? []) { + const canonicalKey = task.record_id ?? task.id; + taskAliases.set(task.id, canonicalKey); + if (task.record_id) taskAliases.set(task.record_id, canonicalKey); + } + const newest = new Map(); + for (const event of scopedEvents) { + if (event.eventType !== "ci_gate_status" && event.eventType !== "main_merge_gate_status") continue; + const rawKey = eventEntityKey(event); + const key = taskAliases.get(rawKey) ?? rawKey; + const current = newest.get(key); + if (!current || isNewerEvent(event, current)) newest.set(key, event); + } + const entities = new Map(); + for (const [key, event] of newest) { + const states = normalizeGateEvent(event); + if (states) entities.set(key, states); + } + + for (const task of input.tasks ?? []) { + if (task.sprint_id && task.sprint_id !== input.sprintId) continue; + const key = task.record_id ?? task.id; + if (!entities.has(key)) { + const fallback = normalizeMergeIndicator(task); + if (fallback) entities.set(key, fallback); + } + } + + for (const attention of activeCiAttention(input.attentionItems ?? [])) { + if (attention.sprintId !== input.sprintId || (input.sprintRunId && attention.sprintRunId !== input.sprintRunId)) continue; + const isMainMerge = stringValue(attention.payload?.mergeStage)?.toLowerCase() === "main"; + const rawKey = isMainMerge + ? "main_merge" + : stringValue(attention.taskId) ?? stringValue(attention.payload?.taskId) ?? stringValue(attention.payload?.taskKey) ?? `attention:${attention.id}`; + const key = taskAliases.get(rawKey) ?? rawKey; + entities.set(key, withCiAttentionFailure(entities.get(key) ?? null, attention)); + } + + return buildPresentation("sprint", [...entities.values()]); +} + +// Compact aliases keep the presentation helper ergonomic at call sites. +export const deriveTaskCiPresentation = deriveTaskCiStatusPresentation; +export const deriveSprintCiPresentation = deriveSprintCiStatusPresentation; +export const getTaskCiStatusPresentation = deriveTaskCiStatusPresentation; +export const getSprintCiStatusPresentation = deriveSprintCiStatusPresentation; diff --git a/tests/dashboard/v2/components/ui/CiStatusBadge.test.tsx b/tests/dashboard/v2/components/ui/CiStatusBadge.test.tsx new file mode 100644 index 0000000000..b297647882 --- /dev/null +++ b/tests/dashboard/v2/components/ui/CiStatusBadge.test.tsx @@ -0,0 +1,77 @@ +/** @vitest-environment happy-dom */ + +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/preact"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; +import { CiStatusBadge } from "../../../../../dashboard/src/v2/components/ui/CiStatusBadge.js"; +import type { CiStatusPresentation } from "../../../../../dashboard/src/v2/lib/ci-status-presentation.js"; + +afterEach(cleanup); + +function presentation(state: CiStatusPresentation["state"]): CiStatusPresentation { + const failed = state === "failed"; + return { + scope: "task", + state, + label: failed ? "CI failed" : state === "in_progress" ? "CI running" : state === "successful" ? "CI passed" : "CI pending", + accessibleLabel: failed + ? "CI failed. Pull request: Pull request ready. Checks: Checks failed. Merge: Blocked by checks." + : "CI running. Pull request: Pull request ready. Checks: Checks running. Merge: Waiting for checks.", + steps: [ + { id: "pull_request", label: "Pull request", state: "successful", statusLabel: "Pull request ready" }, + { id: "checks", label: "Checks", state, statusLabel: failed ? "Checks failed" : state === "in_progress" ? "Checks running" : state === "successful" ? "Checks passed" : "Checks pending", ...(failed ? { failureKind: "ci_checks" as const } : {}) }, + { id: "merge", label: "Merge", state: state === "successful" ? "successful" : "pending", statusLabel: state === "successful" ? "Merged" : "Waiting for checks" }, + ], + ...(failed ? { failureKind: "ci_checks" } : {}), + }; +} + +describe("CiStatusBadge", () => { + it("renders failed checks with a visible, accessible red X treatment", () => { + const { container } = render(); + expect(screen.getByText("CI failed")).toBeVisible(); + const trigger = screen.getByRole("button", { name: /CI status: CI failed.*Show workflow details/i }); + expect(trigger).toHaveClass("text-status-red"); + const failureIcon = container.querySelector('[data-ci-icon="failure"]'); + expect(failureIcon).toBeTruthy(); + expect(failureIcon).toHaveClass("text-status-red"); + }); + + it("opens every workflow step from the keyboard and restores focus on Escape", async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole("button", { name: /Show workflow details/i }); + + await user.tab(); + expect(trigger).toHaveFocus(); + await user.keyboard("{Enter}"); + + expect(trigger).toHaveAttribute("aria-expanded", "true"); + const details = screen.getByRole("region", { name: "CI workflow details" }); + expect(details).toBeVisible(); + expect(screen.getByText("Pull request")).toBeVisible(); + expect(screen.getByText("Checks")).toBeVisible(); + expect(screen.getByText("Merge")).toBeVisible(); + + await user.keyboard("{Escape}"); + expect(screen.queryByRole("region", { name: "CI workflow details" })).toBeNull(); + expect(trigger).toHaveFocus(); + }); + + it("keeps progress animation reduced-motion safe and pending states non-red", () => { + const { container, rerender } = render(); + const progressIcon = container.querySelector('[data-ci-icon="in_progress"]'); + expect(progressIcon).toHaveClass("motion-safe:animate-spin", "motion-reduce:animate-none"); + expect(screen.getByRole("button")).not.toHaveClass("text-status-red"); + + rerender(); + expect(screen.getByText("CI pending")).toBeVisible(); + expect(screen.getByRole("button")).not.toHaveClass("text-status-red"); + }); + + it("renders nothing when the presentation model has no related evidence", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/tests/dashboard/v2/lib/ci-status-presentation.test.ts b/tests/dashboard/v2/lib/ci-status-presentation.test.ts new file mode 100644 index 0000000000..1bb506824b --- /dev/null +++ b/tests/dashboard/v2/lib/ci-status-presentation.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import type { + ExecutionAttentionItemSummary, + ExecutionRuntimeEventSummary, + Subtask, +} from "../../../../src/contracts/app-types.js"; +import { + deriveSprintCiStatusPresentation, + deriveTaskCiStatusPresentation, +} from "../../../../dashboard/src/v2/lib/ci-status-presentation.js"; + +function event(overrides: Partial = {}): ExecutionRuntimeEventSummary { + return { + id: "event-1", + scopeType: "task_run", + taskRunId: "task-run-1", + sprintRunId: "sprint-run-1", + dispatchId: null, + projectId: "project-1", + sprintId: "sprint-1", + sprintName: "Sprint", + sprintNumber: 1, + sprintRunStatus: "running", + taskId: "task-record-1", + taskKey: "T01", + taskTitle: "Task one", + taskRunState: "in_progress", + eventType: "ci_gate_status", + originator: "system", + sourceEventKey: null, + provider: null, + sessionId: null, + sessionName: null, + workerBranch: "worker/t01", + prUrl: "https://example.test/pr/1", + connectionId: null, + connectionDisplayName: null, + connectionRole: null, + createdAt: "2026-07-13T10:00:00.000Z", + payload: { state: "waiting_checks", prNumber: 1, hasPendingChecks: true }, + ...overrides, + }; +} + +function attention(overrides: Partial = {}): ExecutionAttentionItemSummary { + return { + id: "attention-1", + sprintId: "sprint-1", + taskId: "task-record-1", + sprintRunId: "sprint-run-1", + dispatchId: null, + attentionType: "ci_fix_required", + severity: "high", + ownerType: "worker", + status: "open", + assignedWorkerEndpointId: null, + title: "CI fix required", + summaryMarkdown: "Checks failed.", + payload: { taskKey: "T01", prNumber: 1, prUrl: "https://example.test/pr/1" }, + openedAt: "2026-07-13T10:00:00.000Z", + claimedAt: null, + resolvedAt: null, + updatedAt: "2026-07-13T10:00:00.000Z", + ...overrides, + }; +} + +const task: Pick = { + record_id: "task-record-1", + id: "T01", + sprint_id: "sprint-1", + merge_indicator: "CI", + pr_url: "https://example.test/pr/1", +}; + +describe("CI status presentation", () => { + it("scopes task evidence and replaces a stale failure with the newest event", () => { + const presentation = deriveTaskCiStatusPresentation({ + task, + sprintRunId: "sprint-run-1", + events: [ + event({ id: "old-failure", createdAt: "2026-07-13T10:00:00.000Z", payload: { state: "waiting_checks", prNumber: 1, hasFailedChecks: true } }), + event({ id: "new-success", createdAt: "2026-07-13T10:01:00.000Z", payload: { state: "ready_for_merge", prNumber: 1 } }), + event({ id: "other-task", taskId: "task-record-2", taskKey: "T02", createdAt: "2026-07-13T10:02:00.000Z", payload: { state: "waiting_checks", hasFailedChecks: true } }), + event({ id: "other-run", sprintRunId: "sprint-run-2", createdAt: "2026-07-13T10:03:00.000Z", payload: { state: "waiting_checks", hasFailedChecks: true } }), + ], + }); + + expect(presentation?.state).toBe("pending"); + expect(presentation?.steps.find((step) => step.id === "checks")?.state).toBe("successful"); + expect(presentation?.label).toBe("CI pending"); + }); + + it("gives active matching CI attention failure precedence and ignores resolved attention", () => { + const failed = deriveTaskCiStatusPresentation({ + task, + events: [event({ payload: { state: "ready_for_merge", prNumber: 1 } })], + attentionItems: [attention()], + }); + expect(failed?.state).toBe("failed"); + expect(failed?.failureKind).toBe("ci_checks"); + expect(failed?.label).toBe("CI failed"); + + const recovered = deriveTaskCiStatusPresentation({ + task, + events: [event({ payload: { state: "ready_for_merge", prNumber: 1 } })], + attentionItems: [attention({ status: "resolved", resolvedAt: "2026-07-13T10:02:00.000Z" })], + }); + expect(recovered?.state).toBe("pending"); + expect(recovered?.steps[1].state).toBe("successful"); + }); + + it("aggregates newest feature and main merge entities with failure then progress precedence", () => { + const presentation = deriveSprintCiStatusPresentation({ + sprintId: "sprint-1", + sprintRunId: "sprint-run-1", + events: [ + event({ id: "t1-old", payload: { state: "waiting_checks", prNumber: 1, hasFailedChecks: true } }), + event({ id: "t1-new", createdAt: "2026-07-13T10:01:00.000Z", payload: { state: "merge_confirmed", prNumber: 1 } }), + event({ + id: "main", + scopeType: "sprint_run", + taskRunId: null, + taskId: null, + taskKey: null, + eventType: "main_merge_gate_status", + createdAt: "2026-07-13T10:02:00.000Z", + payload: { state: "pending_checks", prNumber: 9 }, + }), + event({ id: "other-sprint", sprintId: "sprint-2", payload: { state: "waiting_checks", hasFailedChecks: true } }), + ], + }); + + expect(presentation?.scope).toBe("sprint"); + expect(presentation?.state).toBe("in_progress"); + expect(presentation?.steps[1].state).toBe("in_progress"); + expect(presentation?.label).toBe("CI running"); + }); + + it("distinguishes check failures from review blockers, conflicts, and missing pull requests", () => { + const checkFailure = deriveTaskCiStatusPresentation({ + task, + events: [event({ payload: { state: "waiting_checks", prNumber: 1, hasFailedChecks: true, hasReviewBlockers: true } })], + }); + expect(checkFailure?.failureKind).toBe("ci_checks"); + + const reviewOnly = deriveTaskCiStatusPresentation({ + task, + events: [event({ payload: { state: "waiting_checks", prNumber: 1, hasReviewBlockers: true } })], + }); + expect(reviewOnly?.state).toBe("pending"); + expect(reviewOnly?.failureKind).toBeUndefined(); + + const conflict = deriveTaskCiStatusPresentation({ + task: { ...task, merge_indicator: "MERGE_CONFLICT" }, + events: [], + }); + expect(conflict?.state).toBe("failed"); + expect(conflict?.failureKind).toBe("merge_conflict"); + expect(conflict?.label).toBe("Merge conflict"); + + const missingPr = deriveTaskCiStatusPresentation({ + task, + events: [event({ prUrl: null, payload: { state: "waiting_for_pr" } })], + }); + expect(missingPr?.state).toBe("pending"); + expect(missingPr?.failureKind).toBeUndefined(); + }); + + it("returns no badge for absent, unrelated, or unknown evidence", () => { + const taskWithoutFallback = { ...task, merge_indicator: undefined, pr_url: undefined }; + expect(deriveTaskCiStatusPresentation({ task: taskWithoutFallback })).toBeNull(); + expect(deriveTaskCiStatusPresentation({ + task: taskWithoutFallback, + events: [event({ eventType: "run_completed", payload: { state: "completed" } })], + })).toBeNull(); + expect(deriveTaskCiStatusPresentation({ + task: taskWithoutFallback, + events: [event({ payload: { state: "future_unknown_state", hasFailedChecks: true } })], + })).toBeNull(); + expect(deriveSprintCiStatusPresentation({ sprintId: "sprint-1", events: [event({ sprintId: "sprint-2" })] })).toBeNull(); + }); + + it("uses main-merge CI attention as a sprint-scoped unresolved check failure", () => { + const presentation = deriveSprintCiStatusPresentation({ + sprintId: "sprint-1", + sprintRunId: "sprint-run-1", + attentionItems: [attention({ taskId: null, payload: { mergeStage: "main", prNumber: 9 } })], + }); + expect(presentation?.state).toBe("failed"); + expect(presentation?.failureKind).toBe("ci_checks"); + }); +}); From 8ca28391379b409f0135b0269800640aed3b7046 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 19:23:08 +0000 Subject: [PATCH 2/2] fix(task T02): address qa review via codex --- dashboard/src/v2/lib/ci-status-presentation.ts | 9 ++++++++- .../v2/lib/ci-status-presentation.test.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/dashboard/src/v2/lib/ci-status-presentation.ts b/dashboard/src/v2/lib/ci-status-presentation.ts index 68a7453c6d..a95a99f2e3 100644 --- a/dashboard/src/v2/lib/ci-status-presentation.ts +++ b/dashboard/src/v2/lib/ci-status-presentation.ts @@ -249,7 +249,14 @@ function normalizeMergeIndicator(evidence: CiTaskMergeEvidence): StepStates | nu case "AUTOMERGE": return completedWorkflow(); case "PR_ONLY": - return completedWorkflow("Merge not required"); + return { + pullRequest: "successful", + checks: "pending", + merge: "successful", + pullRequestLabel: "Pull request ready", + checksLabel: "Checks pending", + mergeLabel: "Merge not required", + }; case "MERGE_CONFLICT": return { pullRequest, checks: "pending", merge: "failed", checksLabel: "Checks pending", mergeLabel: "Merge conflict", mergeFailureKind: "merge_conflict" }; case "MERGE_BLOCKED": diff --git a/tests/dashboard/v2/lib/ci-status-presentation.test.ts b/tests/dashboard/v2/lib/ci-status-presentation.test.ts index 1bb506824b..27cdd75399 100644 --- a/tests/dashboard/v2/lib/ci-status-presentation.test.ts +++ b/tests/dashboard/v2/lib/ci-status-presentation.test.ts @@ -167,6 +167,21 @@ describe("CI status presentation", () => { expect(missingPr?.failureKind).toBeUndefined(); }); + it("keeps PR_ONLY checks pending when no CI event evidence exists", () => { + const presentation = deriveTaskCiStatusPresentation({ + task: { ...task, merge_indicator: "PR_ONLY" }, + events: [], + }); + + expect(presentation?.state).toBe("pending"); + expect(presentation?.steps).toEqual([ + expect.objectContaining({ id: "pull_request", state: "successful", statusLabel: "Pull request ready" }), + expect.objectContaining({ id: "checks", state: "pending", statusLabel: "Checks pending" }), + expect.objectContaining({ id: "merge", state: "successful", statusLabel: "Merge not required" }), + ]); + expect(presentation?.failureKind).toBeUndefined(); + }); + it("returns no badge for absent, unrelated, or unknown evidence", () => { const taskWithoutFallback = { ...task, merge_indicator: undefined, pr_url: undefined }; expect(deriveTaskCiStatusPresentation({ task: taskWithoutFallback })).toBeNull();