From 5b71bdb2842feb9a6e7e7b6b2a27bea635fea69a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 23:47:21 -0700 Subject: [PATCH] FN-7177: color PR badges by GitHub status Align dashboard PR badges with live GitHub PR state colors. - Add a shared PR badge modifier helper for open, draft, merged, closed, and conflict states.\n- Apply the helper to single and multi-PR task card badges so live payload changes repaint correctly.\n- Add token-backed merged/conflict styling, regression coverage, and a patch changeset. Files changed:\n .changeset/fn-7177-pr-badge-status-color.md | 7 ++++\n packages/dashboard/app/components/GitHubBadge.tsx | 8 +++--\n packages/dashboard/app/components/TaskCard.tsx | 9 +++--\n .../app/components/__tests__/GitHubBadge.test.tsx | 1 +\n .../app/components/__tests__/TaskCard.test.tsx | 41 +++++++++++++++++++---\n packages/dashboard/app/styles.css | 15 ++++++--\n .../app/utils/__tests__/prBadgeClass.test.ts | 22 ++++++++++++\n packages/dashboard/app/utils/prBadgeClass.ts | 28 +++++++++++++++\n 8 files changed, 119 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-7177 Fusion-Task-Lineage: 23954f1b-d184-49a6-9635-7d9c1d0aea11 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7177-pr-badge-status-color.md | 7 ++++ .../dashboard/app/components/GitHubBadge.tsx | 8 ++-- .../dashboard/app/components/TaskCard.tsx | 9 +++- .../components/__tests__/GitHubBadge.test.tsx | 1 + .../components/__tests__/TaskCard.test.tsx | 41 +++++++++++++++++-- packages/dashboard/app/styles.css | 15 +++++-- .../app/utils/__tests__/prBadgeClass.test.ts | 22 ++++++++++ packages/dashboard/app/utils/prBadgeClass.ts | 28 +++++++++++++ 8 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 .changeset/fn-7177-pr-badge-status-color.md create mode 100644 packages/dashboard/app/utils/__tests__/prBadgeClass.test.ts create mode 100644 packages/dashboard/app/utils/prBadgeClass.ts diff --git a/.changeset/fn-7177-pr-badge-status-color.md b/.changeset/fn-7177-pr-badge-status-color.md new file mode 100644 index 0000000000..c1c7c84d95 --- /dev/null +++ b/.changeset/fn-7177-pr-badge-status-color.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: PR badge color now follows GitHub status: green/gray/purple/red plus a conflict color. +category: fix +dev: Adds getPrBadgeModifierClass and a token-backed --color-merged badge modifier. diff --git a/packages/dashboard/app/components/GitHubBadge.tsx b/packages/dashboard/app/components/GitHubBadge.tsx index 17de86e7b4..51780a17bb 100644 --- a/packages/dashboard/app/components/GitHubBadge.tsx +++ b/packages/dashboard/app/components/GitHubBadge.tsx @@ -1,6 +1,7 @@ import { GitPullRequest, CircleDot, CheckCircle2, XCircle, Clock } from "lucide-react"; import type { IssueInfo, PrInfo } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; +import { getPrBadgeModifierClass } from "../utils/prBadgeClass"; interface GitHubBadgeProps { prInfo?: PrInfo; @@ -17,19 +18,20 @@ function getIssueModifierClass(state: string, stateReason?: string): string { } export function GitHubBadge({ prInfo, issueInfo, onIssueRefresh: _onIssueRefresh }: GitHubBadgeProps) { - const prState = prInfo?.isDraft || prInfo?.status === "draft" ? "draft" : prInfo?.status; + const prIsDraft = prInfo?.status === "draft" || (prInfo?.status === "open" && (prInfo.draft ?? prInfo.isDraft)); + const prModifierClass = prInfo ? getPrBadgeModifierClass(prInfo) : null; const checkRollup = prInfo?.checkRollup; const checkClass = checkRollup && checkRollup !== "none" ? `card-github-badge__check card-github-badge__check--${checkRollup}` : null; const checkTitle = checkRollup && checkRollup !== "none" ? ` — checks: ${checkRollup}` : ""; const prTitle = prInfo - ? `PR #${prInfo.number}${prState === "draft" ? " (draft)" : ""}: ${prInfo.title}${checkTitle}` + ? `PR #${prInfo.number}${prIsDraft ? " (draft)" : ""}: ${prInfo.title}${checkTitle}` : ""; return ( <> {prInfo && ( { const nextPr = nextTask.prInfos?.[index]; - return nextPr?.number === pr.number && nextPr?.status === pr.status; + /* + FNXC:PRBadgeStatusColor 2026-06-27-12:00: + Multi-PR badge rendering depends on the same live PR fields as getPrBadgeModifierClass, so memoization must compare the full badge payload instead of only number/status to repaint draft and conflict color changes. + */ + return areTaskBadgeInfosEqual(pr, nextPr); }) && areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo) ); @@ -1959,7 +1964,7 @@ function TaskCardComponent({ {(livePrInfo || liveIssueInfo) && ( <> {livePrInfo && (task.prInfos?.length ?? 0) >= 2 ? ( - + {`${task.prInfos?.length}x #${livePrInfo.number}`} diff --git a/packages/dashboard/app/components/__tests__/GitHubBadge.test.tsx b/packages/dashboard/app/components/__tests__/GitHubBadge.test.tsx index ec332dbc27..221ed72649 100644 --- a/packages/dashboard/app/components/__tests__/GitHubBadge.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubBadge.test.tsx @@ -18,6 +18,7 @@ describe("GitHubBadge PR state + rollup", () => { it.each([ { name: "open", prInfo: { status: "open" as const }, expectedClass: "card-github-badge--open" }, { name: "draft", prInfo: { status: "open" as const, isDraft: true }, expectedClass: "card-github-badge--draft" }, + { name: "conflicting", prInfo: { status: "open" as const, mergeable: "conflicting" as const }, expectedClass: "card-github-badge--conflicting" }, { name: "merged", prInfo: { status: "merged" as const }, expectedClass: "card-github-badge--merged" }, { name: "closed", prInfo: { status: "closed" as const }, expectedClass: "card-github-badge--closed" }, ])("applies $name modifier class", ({ prInfo, expectedClass }) => { diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 0fd6169a52..1ce5a57e5e 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -634,7 +634,10 @@ describe("TaskCard", () => { } }); - it("renders Nx PR badge label when multiple PRs are linked", () => { + it.each([ + { name: "merged", primaryPr: { status: "merged" as const }, expectedClass: "card-github-badge--merged" }, + { name: "conflicting", primaryPr: { status: "open" as const, mergeable: "conflicting" as const }, expectedClass: "card-github-badge--conflicting" }, + ])("renders Nx PR badge label and resolver class for $name primary PR", ({ primaryPr, expectedClass }) => { render( { headBranch: "fusion/fn-001", baseBranch: "main", commentCount: 0, + ...primaryPr, } as any, prInfos: [ { @@ -657,6 +661,7 @@ describe("TaskCard", () => { headBranch: "fusion/fn-001", baseBranch: "main", commentCount: 0, + ...primaryPr, }, { url: "https://github.com/owner/repo/pull/99", @@ -674,7 +679,9 @@ describe("TaskCard", () => { />, ); - expect(screen.getByRole("link", { name: /2x #42/i })).toBeDefined(); + const badge = screen.getByRole("link", { name: /2x #42/i }); + expect(badge).toBeDefined(); + expect(badge).toHaveClass(expectedClass); }); it("clicking PR badge link does not open the task detail modal", () => { @@ -1946,7 +1953,7 @@ describe("TaskCard", () => { ); const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent); - // WS-003 has no result → name falls back to the humanized workflow id; all others resolve from result.workflowStepName. + // WS-003 has no result → name falls back to the display-normalized id; all others resolve from result.workflowStepName. expect(stepNames).toEqual([ "Step 0", "Step 1", @@ -2032,7 +2039,7 @@ describe("TaskCard", () => { ); const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent); - // Blank result name → fall back to the humanized id; WS-003 (no result) → humanized id. + // Blank result name → display-normalized id; WS-003 (no result) → display-normalized id. expect(stepNames).toEqual(["WS 002", "WS 003"]); }); @@ -4548,6 +4555,32 @@ describe("TaskCard memo comparator provenance behavior", () => { ).toBe(false); }); + it.each([ + { name: "mergeable", patch: { mergeable: "conflicting" } }, + { name: "draft", patch: { draft: true } }, + { name: "isDraft", patch: { isDraft: true } }, + ])("returns false when multi-PR badge $name color input changes", ({ patch }) => { + const basePr = { + url: "https://github.com/owner/repo/pull/42", + number: 42, + status: "open" as const, + title: "PR", + headBranch: "fusion/fn-001", + baseBranch: "main", + commentCount: 0, + }; + const secondPr = { ...basePr, url: "https://github.com/owner/repo/pull/99", number: 99, title: "PR 2" }; + const previousTask = makeTask({ prInfos: [basePr, secondPr] as any }); + const nextTask = makeTask({ prInfos: [{ ...basePr, ...patch }, secondPr] as any }); + + expect( + __test_areTaskCardPropsEqual( + { task: previousTask, onOpenDetail: noop, addToast: noop } as any, + { task: nextTask, onOpenDetail: noop, addToast: noop } as any, + ), + ).toBe(false); + }); + it("skips customFields JSON.stringify when both cardFieldDefs are absent", () => { // Without cardFieldDefs present, two tasks with different customFields should // compare equal (JSON.stringify is skipped — guard path). diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 70a19f5c2a..bba4c8ad69 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -299,6 +299,7 @@ svg.spinning { --in-progress-rgb: 0, 229, 255; --in-review: #3fb950; --done: #8b949e; + --color-merged: #a371f7; --color-success: #3fb950; --color-error: #f85149; @@ -548,6 +549,7 @@ svg.spinning { --in-progress-rgb: 0, 188, 212; --in-review: #1a7f37; --done: #6e7781; + --color-merged: #8250df; --color-success: #1a7f37; --color-error: #cf222e; --color-muted: #6e7781; @@ -2171,7 +2173,10 @@ input[type="range"]:focus-visible { box-shadow: var(--focus-ring-strong); } -/* GitHub badge theme-aware colors */ +/* + FNXC:PRBadgeStatusColor 2026-06-27-00:00: + PR badge modifiers map live GitHub status to theme tokens in both dark and light roots: open=green, draft=gray, merged=purple, closed=red, and conflicting/blocked=red-caution via --color-error. The existing mobile .card-github-badge base rule controls badge sizing, so modifiers need no breakpoint-specific overrides. +*/ .card-github-badge--open { background: color-mix(in srgb, var(--in-review) 20%, transparent); color: var(--in-review); @@ -2181,8 +2186,12 @@ input[type="range"]:focus-visible { color: var(--color-error); } .card-github-badge--merged { - background: color-mix(in srgb, var(--in-progress) 20%, transparent); - color: var(--in-progress); + background: color-mix(in srgb, var(--color-merged) 20%, transparent); + color: var(--color-merged); +} +.card-github-badge--conflicting { + background: color-mix(in srgb, var(--color-error) 20%, transparent); + color: var(--color-error); } .card-github-badge--draft { background: color-mix(in srgb, var(--text-muted) 18%, transparent); diff --git a/packages/dashboard/app/utils/__tests__/prBadgeClass.test.ts b/packages/dashboard/app/utils/__tests__/prBadgeClass.test.ts new file mode 100644 index 0000000000..8db031843b --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/prBadgeClass.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { getPrBadgeModifierClass } from "../prBadgeClass"; + +describe("getPrBadgeModifierClass", () => { + it.each([ + { name: "open", prInfo: { status: "open" as const }, expectedClass: "card-github-badge--open" }, + { name: "open isDraft", prInfo: { status: "open" as const, isDraft: true }, expectedClass: "card-github-badge--draft" }, + { name: "open draft", prInfo: { status: "open" as const, draft: true }, expectedClass: "card-github-badge--draft" }, + { name: "draft status", prInfo: { status: "draft" as const }, expectedClass: "card-github-badge--draft" }, + { name: "merged", prInfo: { status: "merged" as const }, expectedClass: "card-github-badge--merged" }, + { name: "closed", prInfo: { status: "closed" as const }, expectedClass: "card-github-badge--closed" }, + { name: "conflicting", prInfo: { status: "open" as const, mergeable: "conflicting" as const }, expectedClass: "card-github-badge--conflicting" }, + { name: "blocked", prInfo: { status: "open" as const, mergeable: "blocked" as const }, expectedClass: "card-github-badge--conflicting" }, + ])("maps $name PR state to $expectedClass", ({ prInfo, expectedClass }) => { + expect(getPrBadgeModifierClass(prInfo)).toBe(expectedClass); + }); + + it("prioritizes open conflicts over draft and open status colors", () => { + expect(getPrBadgeModifierClass({ status: "open", isDraft: true, mergeable: "conflicting" })).toBe("card-github-badge--conflicting"); + expect(getPrBadgeModifierClass({ status: "open", draft: true, mergeable: "blocked" })).toBe("card-github-badge--conflicting"); + }); +}); diff --git a/packages/dashboard/app/utils/prBadgeClass.ts b/packages/dashboard/app/utils/prBadgeClass.ts new file mode 100644 index 0000000000..ba6c33e068 --- /dev/null +++ b/packages/dashboard/app/utils/prBadgeClass.ts @@ -0,0 +1,28 @@ +import type { PrInfo } from "@fusion/core"; + +type PrBadgeClassInput = Pick; + +type PrBadgeModifierClass = + | "card-github-badge--conflicting" + | "card-github-badge--draft" + | "card-github-badge--open" + | "card-github-badge--merged" + | "card-github-badge--closed"; + +/** + * FNXC:PRBadgeStatusColor 2026-06-27-00:00: + * PR badges must use one status-color source of truth across TaskCard's multi-PR link and GitHubBadge's single-PR link. Match GitHub's live PR conventions: open=green, draft=gray, merged=purple, closed=red, and open conflicts/blocks=red-caution while checks stay in their separate sub-badge. + */ +export function getPrBadgeModifierClass(prInfo: PrBadgeClassInput): PrBadgeModifierClass { + if (prInfo.status === "open" && (prInfo.mergeable === "conflicting" || prInfo.mergeable === "blocked")) { + return "card-github-badge--conflicting"; + } + + if (prInfo.status === "draft" || (prInfo.status === "open" && (prInfo.draft ?? prInfo.isDraft))) { + return "card-github-badge--draft"; + } + + if (prInfo.status === "merged") return "card-github-badge--merged"; + if (prInfo.status === "closed") return "card-github-badge--closed"; + return "card-github-badge--open"; +}