Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -87,6 +91,7 @@ type MaintainerDashboard = {
qualityDashboard: {
topContributors: MaintainerTopContributor[];
gateOutcomeBreakdown: GateOutcomeCardData;
queueHealth?: MaintainerQueueHealth;
};
};

Expand Down Expand Up @@ -381,6 +386,8 @@ function MaintainerDashboardView({

<GateOutcomeCard breakdown={data.qualityDashboard.gateOutcomeBreakdown} />

<QueueHealthCard queueHealth={data.qualityDashboard.queueHealth} />

<ContributorQualityTable topContributors={data.qualityDashboard.topContributors} />

<ActivationPreview reviewability={data.reviewability} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<QueueHealthCard queueHealth={POPULATED} />);
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(
<QueueHealthCard
queueHealth={{
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 },
}}
/>,
);
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(<QueueHealthCard />);
expect(screen.getByText("Not yet available")).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -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<keyof MaintainerQueueHealth["bandCounts"], Status> = {
low: "ready",
medium: "info",
high: "warn",
critical: "degraded",
};

const BAND_ORDER: Array<keyof MaintainerQueueHealth["bandCounts"]> = [
"low",
"medium",
"high",
"critical",
];

const AGE_BAR: Record<keyof MaintainerQueueHealth["ageBuckets"], string> = {
under7Days: "bg-success",
days7To30: "bg-warning",
over30Days: "bg-danger",
};

const AGE_LABEL: Record<keyof MaintainerQueueHealth["ageBuckets"], string> = {
under7Days: "< 7d",
days7To30: "7–30d",
over30Days: "> 30d",
};

export function QueueHealthCard({ queueHealth }: { queueHealth?: MaintainerQueueHealth }) {
if (!queueHealth || queueHealth.openPullRequests === 0) {
return (
<AnalyticsCardShell
title="Queue health"
description="Open / stale / draft / unlinked PRs across your repos, by age and burden band."
state="empty"
emptyTitle={queueHealth ? "Queue is clear" : "Not yet available"}
emptyHint={
queueHealth
? "No open pull requests across the shaped repos in this window."
: "Queue health appears once the maintainer dashboard payload includes the queue aggregate."
}
/>
);
}

const { openPullRequests, stalePullRequests, draftPullRequests, unlinkedPullRequests } =
queueHealth;
const ageTotal =
queueHealth.ageBuckets.under7Days +
queueHealth.ageBuckets.days7To30 +
queueHealth.ageBuckets.over30Days;

return (
<AnalyticsCardShell
title="Queue health"
description="Open / stale / draft / unlinked PRs across your repos, by age and burden band."
state="ready"
>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="Open PRs" value={String(openPullRequests)} />
<Stat label="Stale" value={String(stalePullRequests)} />
<Stat label="Draft" value={String(draftPullRequests)} />
<Stat label="Unlinked" value={String(unlinkedPullRequests)} />
</div>

<div className="mt-4 space-y-1.5">
<div className="flex items-center justify-between text-token-xs text-muted-foreground">
<span>Open-PR age</span>
<span className="font-mono">{queueHealth.collisionClusters} collision cluster(s)</span>
</div>
<div className="flex h-2 overflow-hidden rounded-full bg-border" aria-hidden>
{ageTotal > 0
? (Object.keys(AGE_BAR) as Array<keyof MaintainerQueueHealth["ageBuckets"]>)
.filter((bucket) => queueHealth.ageBuckets[bucket] > 0)
.map((bucket) => (
<div
key={bucket}
className={cn("h-full", AGE_BAR[bucket])}
style={{ width: `${(queueHealth.ageBuckets[bucket] / ageTotal) * 100}%` }}
/>
))
: null}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 font-mono text-token-2xs text-muted-foreground">
{(Object.keys(AGE_LABEL) as Array<keyof MaintainerQueueHealth["ageBuckets"]>).map(
(bucket) => (
<span key={bucket}>
{AGE_LABEL[bucket]} {queueHealth.ageBuckets[bucket]}
</span>
),
)}
</div>
</div>

<div className="mt-4 flex flex-wrap gap-2">
{BAND_ORDER.filter((band) => queueHealth.bandCounts[band] > 0).map((band) => (
<StatusPill key={band} status={BAND_STATUS[band]}>
{band} {queueHealth.bandCounts[band]}
</StatusPill>
))}
</div>
</AnalyticsCardShell>
);
}
33 changes: 33 additions & 0 deletions src/services/maintainer-quality-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueueHealth["level"], number>;
};
summary: string;
};

Expand Down Expand Up @@ -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<QueueHealth["level"], number>,
};

for (const { repo, issues, pullRequests } of args.repos) {
const openPullRequests = pullRequests.filter((pr) => pr.state === "open");
Expand All @@ -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);
Expand Down Expand Up @@ -138,6 +170,7 @@ export function buildMaintainerQualityDashboard(args: { repos: MaintainerQuality
repoQuality,
topContributors,
qualitySignals: { openPrs, duplicatePrRisk, missingLinkedIssue },
queueHealth: queueHealthAggregate,
summary,
};
}
14 changes: 14 additions & 0 deletions test/unit/maintainer-quality-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"/);
Expand Down
Loading