From b1c2943077f317b0933c4c1e9fffe1aab68ea54d Mon Sep 17 00:00:00 2001 From: dhgoal <153369624+dhgoal@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:11:25 +0900 Subject: [PATCH] feat(analytics): add aggregate queue-health to the maintainer quality dashboard (#2201) Fold the per-repo QueueHealth signals the shaping already computes into a dashboard-level queueHealth aggregate on MaintainerQualityDashboard: summed open/stale/draft/unlinked PR counts, collision clusters, an open-PR age-bucket distribution, and how many repos fall in each burden band. Pure in-memory aggregation over data already produced in the shaping loop (no new IO). Adds QueueHealthCard rendering the counts + age distribution + burden-band pills (counts and bands only, never raw scores), wired into MaintainerPanel and degrading to an empty state when the queue is clear or the field is absent. --- .../site/app-panels/maintainer-panel.tsx | 7 + .../app-panels/queue-health-card.test.tsx | 52 ++++++++ .../site/app-panels/queue-health-card.tsx | 120 ++++++++++++++++++ src/services/maintainer-quality-dashboard.ts | 33 +++++ .../unit/maintainer-quality-dashboard.test.ts | 14 ++ 5 files changed, 226 insertions(+) create mode 100644 apps/gittensory-ui/src/components/site/app-panels/queue-health-card.test.tsx create mode 100644 apps/gittensory-ui/src/components/site/app-panels/queue-health-card.tsx diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx index 8270a8a935..d81b50e5fa 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx @@ -23,6 +23,10 @@ import { ContributorQualityTable } from "@/components/site/app-panels/contributo import type { MaintainerTopContributor } from "@/components/site/app-panels/contributor-quality-table-model"; import { GateOutcomeCard } from "@/components/site/app-panels/gate-outcome-card"; import type { GateOutcomeCardData } from "@/components/site/app-panels/gate-outcome-card-model"; +import { + QueueHealthCard, + type MaintainerQueueHealth, +} from "@/components/site/app-panels/queue-health-card"; import { MaintainerSettings } from "@/components/site/app-panels/maintainer-settings"; import { OnboardingPreviewCard } from "@/components/site/app-panels/onboarding-preview-card"; import { CheckRunReadinessTable } from "@/components/site/check-run-readiness-table"; @@ -87,6 +91,7 @@ type MaintainerDashboard = { qualityDashboard: { topContributors: MaintainerTopContributor[]; gateOutcomeBreakdown: GateOutcomeCardData; + queueHealth?: MaintainerQueueHealth; }; }; @@ -381,6 +386,8 @@ function MaintainerDashboardView({ + + diff --git a/apps/gittensory-ui/src/components/site/app-panels/queue-health-card.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/queue-health-card.test.tsx new file mode 100644 index 0000000000..3e614c7175 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/queue-health-card.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { QueueHealthCard } from "@/components/site/app-panels/queue-health-card"; + +const POPULATED = { + openPullRequests: 12, + stalePullRequests: 3, + draftPullRequests: 2, + unlinkedPullRequests: 1, + collisionClusters: 4, + ageBuckets: { under7Days: 7, days7To30: 3, over30Days: 2 }, + bandCounts: { low: 5, medium: 2, high: 1, critical: 0 }, +}; + +describe("QueueHealthCard", () => { + it("renders the aggregate counts, age buckets, collisions, and non-empty burden bands when populated", () => { + render(); + expect(screen.getByText("Open PRs")).toBeTruthy(); + expect(screen.getByText("12")).toBeTruthy(); + expect(screen.getByText(/4 collision cluster/)).toBeTruthy(); + expect(screen.getByText(/< 7d 7/)).toBeTruthy(); + expect(screen.getByText(/> 30d 2/)).toBeTruthy(); + // Bands with a count render; a zero band (critical) is omitted. + expect(screen.getByText(/low 5/)).toBeTruthy(); + expect(screen.getByText(/high 1/)).toBeTruthy(); + expect(screen.queryByText(/critical/)).toBeNull(); + }); + + it("shows the 'queue is clear' empty state when the aggregate has zero open PRs", () => { + render( + , + ); + expect(screen.getByText("Queue is clear")).toBeTruthy(); + expect(screen.queryByText("Open PRs")).toBeNull(); + }); + + it("shows the 'not yet available' empty state when the queueHealth field is absent", () => { + render(); + expect(screen.getByText("Not yet available")).toBeTruthy(); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/queue-health-card.tsx b/apps/gittensory-ui/src/components/site/app-panels/queue-health-card.tsx new file mode 100644 index 0000000000..93dfd54760 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/queue-health-card.tsx @@ -0,0 +1,120 @@ +import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell"; +import { Stat, StatusPill, type Status } from "@/components/site/control-primitives"; +import { cn } from "@/lib/utils"; + +/** Aggregate PR-queue-health for the maintainer quality dashboard (#2201): summed open/stale/draft/unlinked PR + * counts across the maintainer's shaped repos, an age-bucket distribution, and how many repos fall in each + * burden band. Display slice over the dashboard payload's `queueHealth` aggregate — counts + bands, never raw + * scores. Degrades to an empty state when the field is absent or the queue is empty. */ +export type MaintainerQueueHealth = { + openPullRequests: number; + stalePullRequests: number; + draftPullRequests: number; + unlinkedPullRequests: number; + collisionClusters: number; + ageBuckets: { under7Days: number; days7To30: number; over30Days: number }; + bandCounts: { low: number; medium: number; high: number; critical: number }; +}; + +const BAND_STATUS: Record = { + low: "ready", + medium: "info", + high: "warn", + critical: "degraded", +}; + +const BAND_ORDER: Array = [ + "low", + "medium", + "high", + "critical", +]; + +const AGE_BAR: Record = { + under7Days: "bg-success", + days7To30: "bg-warning", + over30Days: "bg-danger", +}; + +const AGE_LABEL: Record = { + under7Days: "< 7d", + days7To30: "7–30d", + over30Days: "> 30d", +}; + +export function QueueHealthCard({ queueHealth }: { queueHealth?: MaintainerQueueHealth }) { + if (!queueHealth || queueHealth.openPullRequests === 0) { + return ( + + ); + } + + const { openPullRequests, stalePullRequests, draftPullRequests, unlinkedPullRequests } = + queueHealth; + const ageTotal = + queueHealth.ageBuckets.under7Days + + queueHealth.ageBuckets.days7To30 + + queueHealth.ageBuckets.over30Days; + + return ( + +
+ + + + +
+ +
+
+ Open-PR age + {queueHealth.collisionClusters} collision cluster(s) +
+
+ {ageTotal > 0 + ? (Object.keys(AGE_BAR) as Array) + .filter((bucket) => queueHealth.ageBuckets[bucket] > 0) + .map((bucket) => ( +
+ )) + : null} +
+
+ {(Object.keys(AGE_LABEL) as Array).map( + (bucket) => ( + + {AGE_LABEL[bucket]} {queueHealth.ageBuckets[bucket]} + + ), + )} +
+
+ +
+ {BAND_ORDER.filter((band) => queueHealth.bandCounts[band] > 0).map((band) => ( + + {band} {queueHealth.bandCounts[band]} + + ))} +
+ + ); +} diff --git a/src/services/maintainer-quality-dashboard.ts b/src/services/maintainer-quality-dashboard.ts index 41c352f104..bfc171ca1e 100644 --- a/src/services/maintainer-quality-dashboard.ts +++ b/src/services/maintainer-quality-dashboard.ts @@ -46,6 +46,18 @@ export type MaintainerQualityDashboard = { topContributors: MaintainerTopContributor[]; /** Aggregate counts across the SHAPED repos' open PRs — observable facts, not private scores. */ qualitySignals: { openPrs: number; duplicatePrRisk: number; missingLinkedIssue: number }; + /** Aggregate PR-queue-health across the SHAPED repos (#2201): summed open/stale/draft/unlinked PR counts, + * collision clusters, an age-bucket distribution, and how many repos fall in each burden band. Observable + * counts + bands only, never raw scores — folds the per-repo QueueHealth signals the shaping already computes. */ + queueHealth: { + openPullRequests: number; + stalePullRequests: number; + draftPullRequests: number; + unlinkedPullRequests: number; + collisionClusters: number; + ageBuckets: { under7Days: number; days7To30: number; over30Days: number }; + bandCounts: Record; + }; summary: string; }; @@ -79,6 +91,15 @@ export function buildMaintainerQualityDashboard(args: { repos: MaintainerQuality let openPrs = 0; let duplicatePrRisk = 0; let missingLinkedIssue = 0; + const queueHealthAggregate = { + openPullRequests: 0, + stalePullRequests: 0, + draftPullRequests: 0, + unlinkedPullRequests: 0, + collisionClusters: 0, + ageBuckets: { under7Days: 0, days7To30: 0, over30Days: 0 }, + bandCounts: { low: 0, medium: 0, high: 0, critical: 0 } as Record, + }; for (const { repo, issues, pullRequests } of args.repos) { const openPullRequests = pullRequests.filter((pr) => pr.state === "open"); @@ -104,6 +125,17 @@ export function buildMaintainerQualityDashboard(args: { repos: MaintainerQuality highRiskDuplicates: collisions.summary.highRiskCount, }); + // #2201: fold this repo's queue-health signals into the dashboard-level aggregate. + queueHealthAggregate.openPullRequests += queueHealth.signals.openPullRequests; + queueHealthAggregate.stalePullRequests += queueHealth.signals.stalePullRequests; + queueHealthAggregate.draftPullRequests += queueHealth.signals.draftPullRequests; + queueHealthAggregate.unlinkedPullRequests += queueHealth.signals.unlinkedPullRequests; + queueHealthAggregate.collisionClusters += queueHealth.signals.collisionClusters; + queueHealthAggregate.ageBuckets.under7Days += queueHealth.signals.ageBuckets.under7Days; + queueHealthAggregate.ageBuckets.days7To30 += queueHealth.signals.ageBuckets.days7To30; + queueHealthAggregate.ageBuckets.over30Days += queueHealth.signals.ageBuckets.over30Days; + queueHealthAggregate.bandCounts[queueHealth.level] += 1; + for (const pr of openPullRequests) { openPrs += 1; const inHighRiskCluster = highRiskPrNumbers.has(pr.number); @@ -138,6 +170,7 @@ export function buildMaintainerQualityDashboard(args: { repos: MaintainerQuality repoQuality, topContributors, qualitySignals: { openPrs, duplicatePrRisk, missingLinkedIssue }, + queueHealth: queueHealthAggregate, summary, }; } diff --git a/test/unit/maintainer-quality-dashboard.test.ts b/test/unit/maintainer-quality-dashboard.test.ts index bffb3d8d90..b9ced8cf69 100644 --- a/test/unit/maintainer-quality-dashboard.test.ts +++ b/test/unit/maintainer-quality-dashboard.test.ts @@ -49,6 +49,20 @@ describe("buildMaintainerQualityDashboard", () => { expect(dashboard.repoQuality[0]).toMatchObject({ repoFullName: "octo/demo", openPrCount: 2 }); expect(["low", "medium", "high", "critical"]).toContain(dashboard.repoQuality[0]!.queueBand); expect(dashboard.qualitySignals).toMatchObject({ openPrs: 2, missingLinkedIssue: 0 }); + // #2201: the aggregate queue-health folds the per-repo QueueHealth signals — open PRs match qualitySignals, + // and every burden band + age bucket is represented as an observable count. + expect(dashboard.queueHealth.openPullRequests).toBe(2); + expect(dashboard.queueHealth.bandCounts).toMatchObject({ + low: expect.any(Number), + medium: expect.any(Number), + high: expect.any(Number), + critical: expect.any(Number), + }); + expect(dashboard.queueHealth.ageBuckets).toMatchObject({ + under7Days: expect.any(Number), + days7To30: expect.any(Number), + over30Days: expect.any(Number), + }); expect(JSON.stringify(dashboard)).not.toMatch(FORBIDDEN_PUBLIC_TERMS); // The per-repo queue burden score is private — only the band is exposed. expect(JSON.stringify(dashboard)).not.toMatch(/"burdenScore"/);