From a381f5bda95692a1a3fc3d86438de4babbc9daf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:06:01 +0100 Subject: [PATCH] feat(web): improve task PR status hover (#2393) --- .../github/pr-task-icon-draft.test.ts | 9 +- .../components/github/pr-task-icon.test.ts | 31 --- apps/web/components/github/pr-task-icon.tsx | 108 +++++--- .../github/pr-task-status-summary.test.ts | 136 ++++++++++ .../github/pr-task-status-summary.tsx | 241 ++++++++++++++++++ apps/web/e2e/tests/pr/pr-status-badge.spec.ts | 127 +++++++++ .../task/mobile-task-status-summary.spec.ts | 4 + apps/web/src/locales/en/github.json | 11 + apps/web/src/locales/pseudo/github.json | 11 + docs/plans/pr-task-status-summary/plan.md | 165 ++++++++++++ .../task-01-readable-pr-task-summary.md | 148 +++++++++++ docs/specs/INDEX.md | 1 + docs/specs/ui/pr-task-status-summary.md | 77 ++++++ 13 files changed, 1001 insertions(+), 68 deletions(-) create mode 100644 apps/web/components/github/pr-task-status-summary.test.ts create mode 100644 apps/web/components/github/pr-task-status-summary.tsx create mode 100644 docs/plans/pr-task-status-summary/plan.md create mode 100644 docs/plans/pr-task-status-summary/task-01-readable-pr-task-summary.md create mode 100644 docs/specs/ui/pr-task-status-summary.md diff --git a/apps/web/components/github/pr-task-icon-draft.test.ts b/apps/web/components/github/pr-task-icon-draft.test.ts index da7bc2d1e0..0e79cc5f2b 100644 --- a/apps/web/components/github/pr-task-icon-draft.test.ts +++ b/apps/web/components/github/pr-task-icon-draft.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { TaskPR } from "@/lib/types/github"; -import { getPRStatusColor, getPRTooltip } from "./pr-task-icon"; +import { getPRStatusColor } from "./pr-task-icon"; +import { derivePRTaskStatusSummary } from "./pr-task-status-summary"; function draftPR(): TaskPR { return { @@ -40,8 +41,8 @@ describe("draft PR task status", () => { }); it("identifies the draft without claiming it is ready to merge", () => { - const tooltip = getPRTooltip(draftPR()); - expect(tooltip).toContain("Draft"); - expect(tooltip).not.toContain("Ready to merge"); + const summary = derivePRTaskStatusSummary(draftPR(), false); + expect(summary.rows.at(-1)).toEqual({ kind: "merge", status: "draft", tone: "muted" }); + expect(summary.rows.some((row) => row.status === "ready")).toBe(false); }); }); diff --git a/apps/web/components/github/pr-task-icon.test.ts b/apps/web/components/github/pr-task-icon.test.ts index 592cf20099..744f97d063 100644 --- a/apps/web/components/github/pr-task-icon.test.ts +++ b/apps/web/components/github/pr-task-icon.test.ts @@ -4,7 +4,6 @@ import { getPRAggregateStatusColor, areAllOpenPRsReadyToMerge, getPRStatusColor, - getPRTooltip, isPRAwaitingReview, isPRReadyToMerge, isPRWaitingOnBranchProtection, @@ -657,33 +656,3 @@ describe("pickDefaultPR", () => { expect(pickDefaultPR([merged, closed])?.id).toBe("merged"); }); }); - -describe("getPRTooltip", () => { - it("includes 'Ready to merge' when ready", () => { - const pr = makePR({ - state: "open", - review_state: "approved", - checks_state: "success", - mergeable_state: "clean", - }); - expect(getPRTooltip(pr)).toContain("Ready to merge"); - }); - - it("includes 'Mergeable: blocked' when blocked", () => { - const pr = makePR({ - state: "open", - review_state: "approved", - checks_state: "success", - mergeable_state: "blocked", - }); - expect(getPRTooltip(pr)).toContain("Mergeable: blocked"); - expect(getPRTooltip(pr)).not.toContain("Ready to merge"); - }); - - it("omits mergeable when state is empty or unknown", () => { - const empty = makePR({ state: "open", mergeable_state: "" }); - const unknown = makePR({ state: "open", mergeable_state: "unknown" }); - expect(getPRTooltip(empty)).not.toContain("Mergeable:"); - expect(getPRTooltip(unknown)).not.toContain("Mergeable:"); - }); -}); diff --git a/apps/web/components/github/pr-task-icon.tsx b/apps/web/components/github/pr-task-icon.tsx index 0780ffa29b..a70491ffa5 100644 --- a/apps/web/components/github/pr-task-icon.tsx +++ b/apps/web/components/github/pr-task-icon.tsx @@ -2,9 +2,12 @@ import { IconGitPullRequest } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useState, type FocusEvent, type PointerEvent } from "react"; +import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { useAppStore } from "@/components/state-provider"; import type { TaskPR } from "@/lib/types/github"; +import { derivePRTaskStatusSummary, PRTaskStatusSummary } from "./pr-task-status-summary"; const MUTED_FOREGROUND = "text-muted-foreground"; const PURPLE_500 = "text-purple-500"; @@ -166,21 +169,6 @@ export function getPRStatusColor(pr: TaskPR): string { return MUTED_FOREGROUND; } -export function getPRTooltip(pr: TaskPR): string { - const parts = [`PR #${pr.pr_number}: ${pr.pr_title}`]; - if (pr.state !== "open") parts.push(`State: ${pr.state}`); - if (pr.review_state) parts.push(`Review: ${pr.review_state}`); - if (pr.checks_state) parts.push(`CI: ${pr.checks_state}`); - if (isPRDraft(pr)) { - parts.push("Draft"); - } else if (isPRReadyToMerge(pr)) { - parts.push("Ready to merge"); - } else if (pr.mergeable_state && pr.mergeable_state !== "unknown" && pr.state === "open") { - parts.push(`Mergeable: ${pr.mergeable_state}`); - } - return parts.join(" | "); -} - /** * Picks the most attention-worthy color across N PRs. For multi-repo tasks one * red PR should dominate the visual even if the others are green. Terminal @@ -256,52 +244,106 @@ export function PRTaskIcon({ taskId }: { taskId: string }) { return ; } +function usePRTaskTooltipState() { + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + const [dismissed, setDismissed] = useState(false); + + return { + open: !dismissed && (hovered || focused), + onPointerEnter(event: PointerEvent) { + if (event.pointerType !== "mouse") return; + setHovered(true); + setDismissed(false); + }, + onPointerLeave(event: PointerEvent) { + if (event.pointerType !== "mouse") return; + setHovered(false); + if (!focused) setDismissed(false); + }, + onFocus(event: FocusEvent) { + if (!event.currentTarget.matches(":focus-visible")) return; + setFocused(true); + setDismissed(false); + }, + onBlur() { + setFocused(false); + if (!hovered) setDismissed(false); + }, + onEscapeKeyDown() { + setDismissed(true); + }, + }; +} + function SinglePRIcon({ taskId, pr }: { taskId: string; pr: TaskPR }) { + const { t } = useTranslation(); + const tooltip = usePRTaskTooltipState(); + const readyToMerge = isPRReadyToMerge(pr); + const summary = derivePRTaskStatusSummary(pr, readyToMerge); return ( - + - + - {getPRTooltip(pr)} + + + ); } function MultiPRIcon({ taskId, prs }: { taskId: string; prs: TaskPR[] }) { + const { t } = useTranslation(); + const tooltip = usePRTaskTooltipState(); const aggregateColor = aggregatePRStatusColor(prs); const allReady = areAllOpenPRsReadyToMerge(prs); + const summaries = prs.map((pr) => derivePRTaskStatusSummary(pr, isPRReadyToMerge(pr))); return ( - + - - {prs.length} + - -
- {prs.map((pr) => ( -
- - - - {getPRTooltip(pr)} -
- ))} -
+ +
); diff --git a/apps/web/components/github/pr-task-status-summary.test.ts b/apps/web/components/github/pr-task-status-summary.test.ts new file mode 100644 index 0000000000..694f68dd1c --- /dev/null +++ b/apps/web/components/github/pr-task-status-summary.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import type { TaskPR } from "@/lib/types/github"; +import { derivePRTaskStatusSummary } from "./pr-task-status-summary"; + +function makePR(overrides: Partial = {}): TaskPR { + return { + id: "pr-id", + task_id: "task-id", + owner: "kdlbs", + repo: "kandev", + pr_number: 2966, + pr_url: "https://github.com/kdlbs/kandev/pull/2966", + pr_title: "Make pull request status easier to scan", + head_branch: "feat/readable-pr-summary", + base_branch: "main", + author_login: "octocat", + state: "open", + review_state: "", + checks_state: "", + mergeable_state: "", + review_count: 0, + pending_review_count: 0, + comment_count: 0, + unresolved_review_threads: 0, + checks_total: 0, + checks_passing: 0, + additions: 0, + deletions: 0, + created_at: "", + merged_at: null, + closed_at: null, + last_synced_at: null, + updated_at: "", + ...overrides, + }; +} + +describe("structured PR task status summary", () => { + it("derives separate review, CI, and ready-to-merge rows", () => { + const pr = makePR({ + review_state: "approved", + checks_state: "success", + mergeable_state: "clean", + }); + + const summary = derivePRTaskStatusSummary(pr, true); + + expect(summary).toEqual({ + number: 2966, + title: "Make pull request status easier to scan", + rows: [ + { kind: "review", status: "approved", tone: "success" }, + { kind: "ci", status: "passed", tone: "success" }, + { kind: "merge", status: "ready", tone: "success" }, + ], + }); + }); + + it("preserves unknown provider values while omitting missing rows", () => { + const summary = derivePRTaskStatusSummary( + makePR({ + review_state: "queued_for_review" as unknown as TaskPR["review_state"], + checks_state: "", + mergeable_state: "unknown", + }), + false, + ); + + expect(summary.rows).toEqual([ + { + kind: "review", + status: "raw", + tone: "muted", + rawValue: "queued_for_review", + }, + ]); + }); + + it("uses a terminal state row and omits mergeability for merged PRs", () => { + const summary = derivePRTaskStatusSummary( + makePR({ + state: "merged", + review_state: "approved", + checks_state: "success", + mergeable_state: "clean", + }), + false, + ); + + expect(summary.rows).toEqual([ + { kind: "state", status: "merged", tone: "merged" }, + { kind: "review", status: "approved", tone: "success" }, + { kind: "ci", status: "passed", tone: "success" }, + ]); + }); + + it("shows blocked as a wait instead of claiming the PR is ready", () => { + const summary = derivePRTaskStatusSummary( + makePR({ + review_state: "approved", + checks_state: "success", + mergeable_state: "blocked", + }), + false, + ); + + expect(summary.rows.at(-1)).toEqual({ + kind: "merge", + status: "blocked", + tone: "muted", + }); + }); + + it("maps requested changes, failed CI, and conflicts to danger rows", () => { + const summary = derivePRTaskStatusSummary( + makePR({ + review_state: "changes_requested", + checks_state: "failure", + mergeable_state: "dirty", + }), + false, + ); + + expect(summary.rows).toEqual([ + { kind: "review", status: "changes_requested", tone: "danger" }, + { kind: "ci", status: "failed", tone: "danger" }, + { kind: "merge", status: "conflicts", tone: "danger" }, + ]); + }); + + it("keeps draft precedence over a supplied ready state", () => { + const summary = derivePRTaskStatusSummary(makePR({ mergeable_state: "draft" }), true); + + expect(summary.rows).toEqual([{ kind: "merge", status: "draft", tone: "muted" }]); + }); +}); diff --git a/apps/web/components/github/pr-task-status-summary.tsx b/apps/web/components/github/pr-task-status-summary.tsx new file mode 100644 index 0000000000..ab30d3df65 --- /dev/null +++ b/apps/web/components/github/pr-task-status-summary.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { + IconAlertTriangle, + IconCheck, + IconCircleDot, + IconClockHour4, + IconGitMerge, + IconGitPullRequest, + IconGitPullRequestDraft, + IconX, + type Icon as TablerIcon, +} from "@tabler/icons-react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import type { TaskPR } from "@/lib/types/github"; + +export type PRTaskSummaryRowKind = "state" | "review" | "ci" | "merge"; +export type PRTaskSummaryTone = "success" | "danger" | "warning" | "info" | "merged" | "muted"; +export type PRTaskSummaryStatus = + | "merged" + | "closed" + | "approved" + | "changes_requested" + | "pending_review" + | "passed" + | "failed" + | "in_progress" + | "draft" + | "ready" + | "conflicts" + | "behind" + | "blocked" + | "mergeable" + | "raw"; + +export type PRTaskSummaryRow = { + kind: PRTaskSummaryRowKind; + status: PRTaskSummaryStatus; + tone: PRTaskSummaryTone; + rawValue?: string; +}; + +export type PRTaskStatusSummaryData = { + number: number; + title: string; + rows: PRTaskSummaryRow[]; +}; + +function rawRow(kind: PRTaskSummaryRowKind, rawValue: string): PRTaskSummaryRow { + return { kind, status: "raw", tone: "muted", rawValue }; +} + +function deriveStateRow(state: string): PRTaskSummaryRow | null { + if (!state || state === "open") return null; + if (state === "merged") return { kind: "state", status: "merged", tone: "merged" }; + if (state === "closed") return { kind: "state", status: "closed", tone: "danger" }; + return rawRow("state", state); +} + +function deriveReviewRow(reviewState: string): PRTaskSummaryRow | null { + if (!reviewState) return null; + if (reviewState === "approved") { + return { kind: "review", status: "approved", tone: "success" }; + } + if (reviewState === "changes_requested") { + return { kind: "review", status: "changes_requested", tone: "danger" }; + } + if (reviewState === "pending") { + return { kind: "review", status: "pending_review", tone: "info" }; + } + return rawRow("review", reviewState); +} + +function deriveCIRow(checksState: string): PRTaskSummaryRow | null { + if (!checksState) return null; + if (checksState === "success") return { kind: "ci", status: "passed", tone: "success" }; + if (checksState === "failure") return { kind: "ci", status: "failed", tone: "danger" }; + if (checksState === "pending") { + return { kind: "ci", status: "in_progress", tone: "warning" }; + } + return rawRow("ci", checksState); +} + +function deriveMergeRow(pr: TaskPR, readyToMerge: boolean): PRTaskSummaryRow | null { + if (pr.state !== "open") return null; + if (pr.mergeable_state === "draft") { + return { kind: "merge", status: "draft", tone: "muted" }; + } + if (readyToMerge) return { kind: "merge", status: "ready", tone: "success" }; + if (!pr.mergeable_state || pr.mergeable_state === "unknown") return null; + if (pr.mergeable_state === "dirty") { + return { kind: "merge", status: "conflicts", tone: "danger" }; + } + if (pr.mergeable_state === "behind") { + return { kind: "merge", status: "behind", tone: "warning" }; + } + if (pr.mergeable_state === "blocked") { + return { kind: "merge", status: "blocked", tone: "muted" }; + } + if (pr.mergeable_state === "clean") { + return { kind: "merge", status: "mergeable", tone: "muted" }; + } + return rawRow("merge", pr.mergeable_state); +} + +export function derivePRTaskStatusSummary( + pr: TaskPR, + readyToMerge: boolean, +): PRTaskStatusSummaryData { + const rows = [ + deriveStateRow(pr.state), + deriveReviewRow(pr.review_state), + deriveCIRow(pr.checks_state), + deriveMergeRow(pr, readyToMerge), + ].filter((row): row is PRTaskSummaryRow => row !== null); + + return { number: pr.pr_number, title: pr.pr_title, rows }; +} + +const ROW_LABEL_KEYS: Record = { + state: "github:prTaskStatusState", + review: "github:prTaskStatusReview", + ci: "github:prTaskStatusCi", + merge: "github:prTaskStatusMerge", +}; + +const TONE_CLASSES: Record = { + success: "text-emerald-600 dark:text-emerald-400", + danger: "text-red-600 dark:text-red-400", + warning: "text-amber-600 dark:text-amber-400", + info: "text-sky-600 dark:text-sky-400", + merged: "text-purple-600 dark:text-purple-400", + muted: "text-muted-foreground", +}; + +const STATUS_LABEL_KEYS: Record, string> = { + merged: "github:merged", + closed: "github:closed", + approved: "github:approved", + changes_requested: "github:changesRequested", + pending_review: "github:pendingReview", + passed: "github:checkBucketPassed", + failed: "github:checkBucketFailed", + in_progress: "github:checkBucketInProgress", + draft: "github:draft", + ready: "github:readyToMerge", + conflicts: "github:conflicts", + behind: "github:behindBase", + blocked: "github:blocked", + mergeable: "github:mergeable", +}; + +const STATUS_ICONS: Record = { + merged: IconGitMerge, + closed: IconX, + approved: IconCheck, + changes_requested: IconX, + pending_review: IconClockHour4, + passed: IconCheck, + failed: IconX, + in_progress: IconClockHour4, + draft: IconGitPullRequestDraft, + ready: IconGitMerge, + conflicts: IconAlertTriangle, + behind: IconAlertTriangle, + blocked: IconAlertTriangle, + mergeable: IconGitMerge, + raw: IconCircleDot, +}; + +function getStatusText(row: PRTaskSummaryRow, t: TFunction): string { + if (row.status === "raw") return row.rawValue ?? ""; + return t(STATUS_LABEL_KEYS[row.status]); +} + +function SummaryStatusIcon({ status }: { status: PRTaskSummaryStatus }) { + const StatusIcon = STATUS_ICONS[status]; + return