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:
parent
7479d7fca7
commit
f633963b77
6 changed files with 1132 additions and 8 deletions
287
frontend/packages/swarm-ui/src/pages/PrReportPage.tsx
Normal file
287
frontend/packages/swarm-ui/src/pages/PrReportPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
62
frontend/packages/swarm-ui/src/pages/pr-report.ts
Normal file
62
frontend/packages/swarm-ui/src/pages/pr-report.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
69
frontend/packages/swarm-ui/test/pr-report.test.mjs
Normal file
69
frontend/packages/swarm-ui/test/pr-report.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue