Watch
0
0
Fork
You've already forked hyperhive
0

swarm ui: a pull requests card on the reports tab

The reports tab gets a "pull requests" card under the issue report. It lists
the open PRs of every repo, or of one picked in a repo select, with the
columns a reviewer or merger filters on: author, labels, requested
reviewers, base branch, WIP (the forge's draft flag), approvals against the
base branch's protection rule, has all approvals, conflicts, protected base
and CI. Every column sorts, the small-set columns filter by picking values,
and the browser keeps the filters and the repo. No filter applies at first.

swarm-controller resolves each row so the page makes no calls of its own:
GET /api/pr-report and GET /api/repos/{org}/{repo}/pr-report. The required
approvals come from the first matching branch protection rule (an
unprotected base asks for none); an approval counts when it is the
reviewer's latest decision, on the current head, neither stale nor
dismissed and official.

A free-text table cell gets a minimum width, so a table with many columns
scrolls sideways instead of wrapping a title to a word a line.
This commit is contained in:
iris 2026-11-07 10:06:59 +01:00 • committed by mara
commit f633963b77
6 changed files with 1132 additions and 8 deletions

View file

@ -0,0 +1,287 @@
// <PrReportCard> — a card of the reports tab: the open pull requests of every
// repo that has one, the way the issue report lists issues. Backed by
// swarm-controller's `GET /api/pulls/repos` (the repo dropdown),
// `GET /api/pr-report` (every repo) and `GET /api/repos/{org}/{repo}/pr-report`
// (one repo): the flags a reviewer or merger filters on — WIP, has all required
// approvals, has conflicts, targets a protected branch — arrive resolved per
// row, so nothing here walks reviews or branch rules.
//
// The table's own column filters and sort do the narrowing, over what is
// loaded; only the repo selection fetches again. No default filter is applied.
// The filters the user sets are kept in the browser (the table's storage key,
// and the repo choice).
import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { useLocalSetting } from "@hive/shared/settings-storage.js";
import { LazyDisclosure } from "../ui/disclosure/LazyDisclosure.js";
import { ExternalLink } from "../ui/external-link/ExternalLink.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import { SelectField } from "../ui/select-field/SelectField.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
import { TableSkeleton } from "../ui/table/TableSkeleton.js";
import { ALL_REPOS, repoOptions } from "./issue-report-repo.js";
import {
approvalsText,
hasAllApprovals,
prReportUrl,
yesNo,
type PrReportRow,
} from "./pr-report.js";
const REPO_FILTER_KEY = "swarm-ui:pr-report:repo-filter";
const TABLE_FILTERS_KEY = "swarm-ui:pr-report:table-filters";
const CI_TONE: Record<NonNullable<PrReportRow["ci"]>, BadgeTone> = {
success: "positive",
pending: "warning",
failure: "negative",
};
const COLUMNS: TableColumn<PrReportRow>[] = [
{
key: "repo",
header: "repo",
render: (r) => r.repo,
sortBy: (r) => r.repo,
filterValues: (r) => [r.repo],
filterMode: "multiselect",
},
{
key: "pr",
header: "PR",
render: (r) =>
r.html_url ? (
<ExternalLink href={r.html_url}>#{r.number}</ExternalLink>
) : (
`#${r.number}`
),
sortBy: (r) => r.number,
},
{
key: "title",
header: "title",
render: (r) => r.title,
sortBy: (r) => r.title,
filterValue: (r) => r.title,
cellClass: "ui-table-prose",
},
{
key: "author",
header: "author",
render: (r) => r.author ?? "—",
sortBy: (r) => r.author ?? "",
filterValues: (r) => (r.author ? [r.author] : []),
filterMode: "multiselect",
},
{
key: "labels",
header: "labels",
render: (r) => (r.labels.length ? r.labels.join(", ") : "—"),
filterValues: (r) => r.labels,
filterMode: "multiselect",
},
{
key: "reviewers",
header: "reviewers",
render: (r) =>
r.requested_reviewers.length ? r.requested_reviewers.join(", ") : "—",
filterValues: (r) => r.requested_reviewers,
filterMode: "multiselect",
},
{
key: "base",
header: "into",
render: (r) => r.base_branch,
sortBy: (r) => r.base_branch,
filterValues: (r) => [r.base_branch],
filterMode: "multiselect",
},
{
key: "wip",
header: "WIP",
render: (r) => (r.wip ? <Badge tone="accent" value="WIP" /> : "—"),
sortBy: (r) => Number(r.wip),
filterValues: (r) => [yesNo(r.wip)],
filterMode: "multiselect",
},
{
key: "approvals",
header: "approvals",
render: (r) => (
<>
{approvalsText(r)}
{r.changes_requested > 0 ? (
<>
{" "}
<Badge tone="negative" value="changes requested" />
</>
) : null}
</>
),
sortBy: (r) => r.approvals,
},
{
key: "approved",
header: "has all approvals",
render: (r) =>
hasAllApprovals(r) ? <Badge tone="positive" value="yes" /> : "no",
sortBy: (r) => Number(hasAllApprovals(r)),
filterValues: (r) => [yesNo(hasAllApprovals(r))],
filterMode: "multiselect",
},
{
key: "conflicts",
header: "conflicts",
render: (r) =>
r.has_conflicts ? <Badge tone="negative" value="conflicts" /> : "—",
sortBy: (r) => Number(r.has_conflicts),
filterValues: (r) => [yesNo(r.has_conflicts)],
filterMode: "multiselect",
},
{
key: "protected",
header: "protected base",
render: (r) => yesNo(r.targets_protected_branch),
sortBy: (r) => Number(r.targets_protected_branch),
filterValues: (r) => [yesNo(r.targets_protected_branch)],
filterMode: "multiselect",
},
{
key: "ci",
header: "CI",
render: (r) => (r.ci ? <Badge tone={CI_TONE[r.ci]} value={r.ci} /> : "—"),
sortBy: (r) => r.ci ?? "",
filterValues: (r) => [r.ci ?? "none"],
filterMode: "multiselect",
},
{
key: "updated",
header: "updated",
render: (r) =>
r.updated_unix !== null ? (
<RelativeTime epochMs={r.updated_unix * 1000} />
) : (
"—"
),
sortBy: (r) => r.updated_unix ?? 0,
},
];
function PrReportBody() {
const [repos, setRepos] = useState<string[] | null>(null);
const [repoFilter, setRepoFilter] = useLocalSetting(
REPO_FILTER_KEY,
ALL_REPOS,
);
const [rows, setRows] = useState<PrReportRow[] | null>(null);
// One slot per request, so a report that loads does not clear a repo list
// that failed.
const [reposError, setReposError] = useState<ProblemDetails | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [loading, setLoading] = useState(false);
// The dropdown's source, read once: a repo gaining or losing its first or
// last open PR between visits is rare enough that a reload covers it.
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch("/api/pulls/repos");
if (!r.ok) {
if (!cancelled) setReposError(await readApiError(r));
return;
}
const data = (await r.json()) as string[];
if (!cancelled) setRepos(data);
})().catch((e: unknown) => {
if (!cancelled) setReposError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, []);
// The one fetch that re-runs on a state change: the repo choice.
useEffect(() => {
let cancelled = false;
setLoading(true);
// A failed report drops the rows, so the table never shows another
// repo's PRs under this repo's error.
const fail = (problem: ProblemDetails) => {
setRows(null);
setError(problem);
};
(async () => {
const r = await fetch(prReportUrl(repoFilter));
if (!r.ok) {
const problem = await readApiError(r);
if (!cancelled) fail(problem);
return;
}
const data = (await r.json()) as PrReportRow[];
if (cancelled) return;
setRows(data);
setError(null);
})()
.catch((e: unknown) => {
if (!cancelled) fail({ detail: String(e) });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [repoFilter]);
return (
<>
<SelectField
id="pr-report-repo"
label="repo"
value={repoFilter}
onChange={setRepoFilter}
options={repoOptions(repos, repoFilter, "pull requests")}
/>
{reposError ? (
<ApiErrorPanel
context="failed to load the repo list"
problem={reposError}
/>
) : null}
{error ? (
<ApiErrorPanel
context="failed to load the pull requests"
problem={error}
/>
) : null}
{!error && loading ? (
<TableSkeleton columns={COLUMNS} label="the pull requests" />
) : null}
{!loading && rows ? (
<Table
columns={COLUMNS}
rows={rows}
rowKey={(r) => `${r.repo}#${r.number}`}
emptyMessage="no open pull requests"
storageKey={TABLE_FILTERS_KEY}
/>
) : null}
</>
);
}
// A folded card on the reports tab: the repo list and the report are read only
// once it is opened.
export function PrReportCard() {
return (
<LazyDisclosure
title="pull requests"
icon="📬"
hint="open pull requests across every repo: approvals, conflicts, CI"
>
<PrReportBody />
</LazyDisclosure>
);
}

View file

@ -27,6 +27,8 @@ export function reportUrl(repoFilter: string): string {
export function repoOptions(
repos: string[] | null,
selected: string,
/** What the missing repo has none of: "issues", or "pull requests". */
what = "issues",
): RepoOption[] {
const options: RepoOption[] = [
{ value: ALL_REPOS, label: "all repos" },
@ -35,7 +37,7 @@ export function repoOptions(
if (selected !== ALL_REPOS && !(repos ?? []).includes(selected)) {
options.push({
value: selected,
label: repos === null ? selected : `${selected} (no open issues)`,
label: repos === null ? selected : `${selected} (no open ${what})`,
});
}
return options;

View file

@ -0,0 +1,62 @@
// Pure pieces of the PR report: the row the controller returns, the report
// URL for a repo filter, and the words its flag columns filter on. Kept out of
// the page so the cases are unit tested.
/** One open pull request, as `GET /api/pr-report` returns it. */
export interface PrReportRow {
/** `owner/name`. */
repo: string;
number: number;
title: string;
html_url: string | null;
author: string | null;
labels: string[];
assignees: string[];
requested_reviewers: string[];
base_branch: string;
head_branch: string;
created_unix: number | null;
updated_unix: number | null;
/** The forge's draft flag. */
wip: boolean;
has_conflicts: boolean;
/** The base branch has a branch protection rule. */
targets_protected_branch: boolean;
required_approvals: number;
approvals: number;
/** Reviewers whose latest review asks for changes. */
changes_requested: number;
/** `null` when the head has no commit status. */
ci: "success" | "pending" | "failure" | null;
}
/** The report URL for a repo filter. The path parts are encoded, so a name
* with `?`, `#` or `%` stays one path segment. A filter without a slash is
* not a repo, so it reads as all repos. */
export function prReportUrl(repoFilter: string): string {
const i = repoFilter.indexOf("/");
if (i < 0) return "/api/pr-report";
const org = encodeURIComponent(repoFilter.slice(0, i));
const name = encodeURIComponent(repoFilter.slice(i + 1));
return `/api/repos/${org}/${name}/pr-report`;
}
/** What a flag column offers as filter values. */
export function yesNo(flag: boolean): string {
return flag ? "yes" : "no";
}
/** The approvals meet the base branch's rule. An unprotected base asks for
* none, so its PRs have them all. */
export function hasAllApprovals(row: PrReportRow): boolean {
return row.approvals >= row.required_approvals;
}
/** The approvals cell: how many of the rule's approvals the PR has, or a dash
* when its base branch asks for none. */
export function approvalsText(row: PrReportRow): string {
if (!row.targets_protected_branch || row.required_approvals === 0) {
return row.approvals > 0 ? `${row.approvals}` : "—";
}
return `${row.approvals}/${row.required_approvals}`;
}

View file

@ -1,8 +1,9 @@
// <ReportsPage> — `/reports`: what the swarm reports about itself, as two
// folded cards: the alerts Grafana holds now, and mara's issue report. Neither
// reads anything until it is opened.
// <ReportsPage> — `/reports`: what the swarm reports about itself, as folded
// cards: the alerts Grafana holds now, mara's issue report and the PR report.
// None reads anything until it is opened.
import { AlertsCard } from "../AlertsPage.js";
import { IssueReportCard } from "../IssueReportPage.js";
import { PrReportCard } from "../PrReportPage.js";
import "./ReportsPage.css";
export function ReportsPage() {
@ -10,6 +11,7 @@ export function ReportsPage() {
<div class="ui-reports-page">
<AlertsCard />
<IssueReportCard />
<PrReportCard />
</div>
);
}

View file

@ -31,6 +31,9 @@
not a short discrete label) rather than open-ended growth that drags
the whole table wider — caps the column and wraps within it instead. */
.ui-table-prose {
/* A table with many columns squeezes free text to a word a line;
the table scrolls sideways instead. */
min-width: 14em;
max-width: 32em;
white-space: normal;
word-break: break-word;

View file

@ -0,0 +1,69 @@
// The PR report's URL and the words its columns show and filter on.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
approvalsText,
hasAllApprovals,
prReportUrl,
yesNo,
} from "../src/pages/pr-report.ts";
test("no repo, or a filter that is not owner/name, reads as all repos", () => {
assert.equal(prReportUrl(""), "/api/pr-report");
assert.equal(prReportUrl("nothing"), "/api/pr-report");
});
test("a repo has its own URL, with each part encoded", () => {
assert.equal(
prReportUrl("hyperhive/hyperhive"),
"/api/repos/hyperhive/hyperhive/pr-report",
);
assert.equal(
prReportUrl("o/a?b#c%d"),
"/api/repos/o/a%3Fb%23c%25d/pr-report",
);
});
test("a flag filters on yes and no", () => {
assert.equal(yesNo(true), "yes");
assert.equal(yesNo(false), "no");
});
const row = (over) => ({
targets_protected_branch: true,
required_approvals: 2,
approvals: 1,
...over,
});
test("approvals read as have/required against a protected base", () => {
assert.equal(approvalsText(row({})), "1/2");
assert.equal(approvalsText(row({ approvals: 0 })), "0/2");
});
test("an unprotected base, or a rule asking for none, shows only what it has", () => {
assert.equal(
approvalsText(row({ targets_protected_branch: false, approvals: 0 })),
"—",
);
assert.equal(
approvalsText(row({ required_approvals: 0, approvals: 2 })),
"2",
);
});
test("a PR has all approvals when it meets the rule, and an unprotected base always does", () => {
assert.equal(hasAllApprovals(row({})), false);
assert.equal(hasAllApprovals(row({ approvals: 2 })), true);
assert.equal(hasAllApprovals(row({ approvals: 3 })), true);
assert.equal(
hasAllApprovals(
row({
targets_protected_branch: false,
required_approvals: 0,
approvals: 0,
}),
),
true,
);
});