Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/PrReportPage.tsx
iris f633963b77 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.
2026-11-07 11:46:05 +01:00

287 lines
8.3 KiB
TypeScript

// <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>
);
}