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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 23:47:21 -07:00
parent 411806163f
commit 5b71bdb284
8 changed files with 119 additions and 12 deletions

View File

@@ -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.

View File

@@ -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 && (
<a
className={`card-github-badge card-github-badge--${prState}`}
className={`card-github-badge ${prModifierClass}`}
title={prTitle}
href={prInfo.url}
target="_blank"

View File

@@ -28,6 +28,7 @@ import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inR
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../utils/taskAgeStalenessCopy";
import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
@@ -678,7 +679,11 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
((previousTask.prInfos?.length ?? 0) === (nextTask.prInfos?.length ?? 0)) &&
(previousTask.prInfos ?? []).every((pr, index) => {
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 ? (
<a className={`card-github-badge card-github-badge--${livePrInfo.status}`} title={t("tasks.prBadgeTitle", "PR #{{number}}: {{title}}", { number: livePrInfo.number, title: livePrInfo.title })} href={livePrInfo.url} target="_blank" rel="noopener noreferrer">
<a className={`card-github-badge ${getPrBadgeModifierClass(livePrInfo)}`} title={t("tasks.prBadgeTitle", "PR #{{number}}: {{title}}", { number: livePrInfo.number, title: livePrInfo.title })} href={livePrInfo.url} target="_blank" rel="noopener noreferrer">
<GitPullRequest size={10} />
<span>{`${task.prInfos?.length}x #${livePrInfo.number}`}</span>
</a>

View File

@@ -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 }) => {

View File

@@ -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(
<TaskCard
task={makeTask({
@@ -647,6 +650,7 @@ describe("TaskCard", () => {
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).

View File

@@ -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);

View File

@@ -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");
});
});

View File

@@ -0,0 +1,28 @@
import type { PrInfo } from "@fusion/core";
type PrBadgeClassInput = Pick<PrInfo, "status" | "isDraft" | "draft" | "mergeable">;
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";
}