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.
287 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|