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
12 changed files with 1132 additions and 8 deletions
|
|
@ -587,6 +587,37 @@ fail on their own: a repo list that fails to load shows its error beside the
|
||||||
report, and a report that fails to load clears the table instead of leaving the
|
report, and a report that fails to load clears the table instead of leaving the
|
||||||
previous repo's issues under the error.
|
previous repo's issues under the error.
|
||||||
|
|
||||||
|
### Pull requests
|
||||||
|
|
||||||
|
The **pull requests** card, below the issue report on the **reports** tab, lists the open
|
||||||
|
pull requests of every repo that has one, from `GET /api/pr-report`, or of one
|
||||||
|
repo from `GET /api/repos/{org}/{repo}/pr-report`. It starts shut and reads
|
||||||
|
nothing until you open it. The **repo** select lists the repos from
|
||||||
|
`GET /api/pulls/repos`, and picking one fetches that repo's report. No filter
|
||||||
|
applies at first.
|
||||||
|
|
||||||
|
Each row shows the repo, the PR, its title, author, labels, requested reviewers
|
||||||
|
and base branch, and what a reviewer or merger filters on:
|
||||||
|
|
||||||
|
- **WIP**: the forge's draft flag. Giving the title a `WIP:` prefix sets it.
|
||||||
|
- **approvals**: how many approvals count against how many the base branch's protection
|
||||||
|
rule asks for (`1/2`). An approval counts when it stands as the reviewer's latest
|
||||||
|
decision, on the PR's current head, neither stale nor dismissed, and from a
|
||||||
|
reviewer whose approval the rule accepts. A reviewer whose latest review asks for changes shows **changes
|
||||||
|
requested**.
|
||||||
|
- **has all approvals**: the approvals meet the rule. A PR into a branch with no
|
||||||
|
protection rule has nothing to approve, so it has them all.
|
||||||
|
- **conflicts**: the forge can't merge the PR as it stands.
|
||||||
|
- **protected base**: the base branch has a protection rule. A stacked PR targets a
|
||||||
|
feature branch, so filtering this to **no** lists them.
|
||||||
|
- **CI**: the commit statuses on the head, as one state (`success`, `pending`,
|
||||||
|
`failure`, or a dash when it has none).
|
||||||
|
|
||||||
|
Every column sorts, and the columns with a small set of values (repo, author,
|
||||||
|
labels, reviewers, base, the flags and CI) filter by picking values. The browser
|
||||||
|
keeps the filters you set and the repo you picked. The card fetches the report once per
|
||||||
|
repo choice, with no refresh timer.
|
||||||
|
|
||||||
### Builds
|
### Builds
|
||||||
|
|
||||||
The builds sit in a card under the job graph on the **jobs** tab. While the graph
|
The builds sit in a card under the job graph on the **jobs** tab. While the graph
|
||||||
|
|
|
||||||
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(
|
export function repoOptions(
|
||||||
repos: string[] | null,
|
repos: string[] | null,
|
||||||
selected: string,
|
selected: string,
|
||||||
|
/** What the missing repo has none of: "issues", or "pull requests". */
|
||||||
|
what = "issues",
|
||||||
): RepoOption[] {
|
): RepoOption[] {
|
||||||
const options: RepoOption[] = [
|
const options: RepoOption[] = [
|
||||||
{ value: ALL_REPOS, label: "all repos" },
|
{ value: ALL_REPOS, label: "all repos" },
|
||||||
|
|
@ -35,7 +37,7 @@ export function repoOptions(
|
||||||
if (selected !== ALL_REPOS && !(repos ?? []).includes(selected)) {
|
if (selected !== ALL_REPOS && !(repos ?? []).includes(selected)) {
|
||||||
options.push({
|
options.push({
|
||||||
value: selected,
|
value: selected,
|
||||||
label: repos === null ? selected : `${selected} (no open issues)`,
|
label: repos === null ? selected : `${selected} (no open ${what})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return options;
|
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
|
// <ReportsPage> — `/reports`: what the swarm reports about itself, as folded
|
||||||
// folded cards: the alerts Grafana holds now, and mara's issue report. Neither
|
// cards: the alerts Grafana holds now, mara's issue report and the PR report.
|
||||||
// reads anything until it is opened.
|
// None reads anything until it is opened.
|
||||||
import { AlertsCard } from "../AlertsPage.js";
|
import { AlertsCard } from "../AlertsPage.js";
|
||||||
import { IssueReportCard } from "../IssueReportPage.js";
|
import { IssueReportCard } from "../IssueReportPage.js";
|
||||||
|
import { PrReportCard } from "../PrReportPage.js";
|
||||||
import "./ReportsPage.css";
|
import "./ReportsPage.css";
|
||||||
|
|
||||||
export function ReportsPage() {
|
export function ReportsPage() {
|
||||||
|
|
@ -10,6 +11,7 @@ export function ReportsPage() {
|
||||||
<div class="ui-reports-page">
|
<div class="ui-reports-page">
|
||||||
<AlertsCard />
|
<AlertsCard />
|
||||||
<IssueReportCard />
|
<IssueReportCard />
|
||||||
|
<PrReportCard />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@
|
||||||
not a short discrete label) rather than open-ended growth that drags
|
not a short discrete label) rather than open-ended growth that drags
|
||||||
the whole table wider — caps the column and wraps within it instead. */
|
the whole table wider — caps the column and wraps within it instead. */
|
||||||
.ui-table-prose {
|
.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;
|
max-width: 32em;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
word-break: break-word;
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -56,6 +56,7 @@ mod meta_repo;
|
||||||
pub mod objects;
|
pub mod objects;
|
||||||
pub mod operator_profiles;
|
pub mod operator_profiles;
|
||||||
pub(crate) mod pr_comment;
|
pub(crate) mod pr_comment;
|
||||||
|
pub mod pr_report;
|
||||||
pub mod pull_diff;
|
pub mod pull_diff;
|
||||||
pub mod review_request;
|
pub mod review_request;
|
||||||
pub mod runner_token;
|
pub mod runner_token;
|
||||||
|
|
|
||||||
515
swarm-controller/src/forge/pr_report.rs
Normal file
515
swarm-controller/src/forge/pr_report.rs
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
//! The open pull requests of a repo as the swarm UI's PR report reads them:
|
||||||
|
//! each with what a reviewer or merger wants to filter on already resolved, so
|
||||||
|
//! the page does no calls of its own. Read-only, over the controller's forge
|
||||||
|
//! token.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use forgejo_api::structs::{
|
||||||
|
BranchProtection, CombinedStatus, CommitStatusState, PullRequest, PullReview,
|
||||||
|
RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
|
||||||
|
};
|
||||||
|
use futures_util::{StreamExt as _, TryStreamExt as _};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use super::Client;
|
||||||
|
|
||||||
|
/// What the commit statuses on a PR's head say together.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CiState {
|
||||||
|
Success,
|
||||||
|
Pending,
|
||||||
|
Failure,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One open pull request, resolved for the PR report.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, ToSchema)]
|
||||||
|
pub struct PrReportRow {
|
||||||
|
/// `owner/name`, on every row so the all-repos and the one-repo report
|
||||||
|
/// share one row type.
|
||||||
|
pub repo: String,
|
||||||
|
pub number: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub html_url: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub labels: Vec<String>,
|
||||||
|
pub assignees: Vec<String>,
|
||||||
|
pub requested_reviewers: Vec<String>,
|
||||||
|
pub base_branch: String,
|
||||||
|
pub head_branch: String,
|
||||||
|
pub created_unix: Option<i64>,
|
||||||
|
pub updated_unix: Option<i64>,
|
||||||
|
/// The forge's draft flag (setting a `WIP:` title is what sets it).
|
||||||
|
pub wip: bool,
|
||||||
|
/// The forge cannot merge the PR as it is: its head conflicts with the
|
||||||
|
/// base.
|
||||||
|
pub has_conflicts: bool,
|
||||||
|
/// The base branch has a branch protection rule. A stacked PR targets a
|
||||||
|
/// feature branch, so `false` filters those out.
|
||||||
|
pub targets_protected_branch: bool,
|
||||||
|
/// Approvals the protection rule asks for; 0 when the base is not
|
||||||
|
/// protected.
|
||||||
|
pub required_approvals: u32,
|
||||||
|
/// Approvals that count now: the reviewer's latest review is an approval
|
||||||
|
/// of the current head, not dismissed, not stale, and counts toward the
|
||||||
|
/// rule (the forge's `official`).
|
||||||
|
pub approvals: u32,
|
||||||
|
/// Reviewers whose latest review asks for changes and is not dismissed.
|
||||||
|
pub changes_requested: u32,
|
||||||
|
/// `None` when the head has no commit status at all.
|
||||||
|
pub ci: Option<CiState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `branch` is named by a branch protection pattern: a name, or a glob
|
||||||
|
/// where `*` stops at a `/` and `**` does not.
|
||||||
|
fn glob_matches(pattern: &str, branch: &str) -> bool {
|
||||||
|
fn go(p: &[u8], s: &[u8]) -> bool {
|
||||||
|
match p.split_first() {
|
||||||
|
None => s.is_empty(),
|
||||||
|
Some((b'*', rest)) => {
|
||||||
|
if let Some(rest) = rest.strip_prefix(b"*") {
|
||||||
|
// `**`: any run, slashes included.
|
||||||
|
(0..=s.len()).any(|i| go(rest, &s[i..]))
|
||||||
|
} else {
|
||||||
|
// `*`: any run up to the next slash.
|
||||||
|
let reach = s.iter().position(|&c| c == b'/').unwrap_or(s.len());
|
||||||
|
(0..=reach).any(|i| go(rest, &s[i..]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((&c, rest)) => s
|
||||||
|
.split_first()
|
||||||
|
.is_some_and(|(&d, tail)| c == d && go(rest, tail)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go(pattern.as_bytes(), branch.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The protection rule for `base`, as the approvals it asks for: `None` when
|
||||||
|
/// no rule names the branch. The first matching rule wins, which is how the
|
||||||
|
/// forge orders them.
|
||||||
|
fn required_approvals_for(rules: &[BranchProtection], base: &str) -> Option<u32> {
|
||||||
|
rules
|
||||||
|
.iter()
|
||||||
|
.find(|r| {
|
||||||
|
r.rule_name
|
||||||
|
.as_deref()
|
||||||
|
.or(r.branch_name.as_deref())
|
||||||
|
.is_some_and(|p| glob_matches(p, base))
|
||||||
|
})
|
||||||
|
.map(|r| u32::try_from(r.required_approvals.unwrap_or(0)).unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a PR's reviews stand against its current head.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
struct Tally {
|
||||||
|
approvals: u32,
|
||||||
|
changes_requested: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each reviewer's latest review decides: an approval counts when it is of
|
||||||
|
/// `head_sha`, not dismissed, not stale and official; a review asking for
|
||||||
|
/// changes stands until it is dismissed or the reviewer reviews again. A
|
||||||
|
/// review that only comments does not replace an earlier decision.
|
||||||
|
fn tally_reviews(reviews: &[PullReview], head_sha: &str) -> Tally {
|
||||||
|
let mut latest: HashMap<&str, &PullReview> = HashMap::new();
|
||||||
|
for review in reviews {
|
||||||
|
let Some(login) = review.user.as_ref().and_then(|u| u.login.as_deref()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let decides = matches!(
|
||||||
|
review.state.as_deref(),
|
||||||
|
Some("APPROVED" | "REQUEST_CHANGES")
|
||||||
|
);
|
||||||
|
if decides && review.dismissed != Some(true) {
|
||||||
|
latest.insert(login, review);
|
||||||
|
} else if decides {
|
||||||
|
// A dismissed decision no longer stands, and neither does the
|
||||||
|
// one it replaced.
|
||||||
|
latest.remove(login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut tally = Tally {
|
||||||
|
approvals: 0,
|
||||||
|
changes_requested: 0,
|
||||||
|
};
|
||||||
|
for review in latest.values() {
|
||||||
|
match review.state.as_deref() {
|
||||||
|
Some("APPROVED") => {
|
||||||
|
if review.commit_id.as_deref() == Some(head_sha)
|
||||||
|
&& review.stale != Some(true)
|
||||||
|
&& review.official == Some(true)
|
||||||
|
{
|
||||||
|
tally.approvals += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => tally.changes_requested += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tally
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The commit statuses on a head, as one state; `None` when it has none.
|
||||||
|
fn ci_state(status: &CombinedStatus) -> Option<CiState> {
|
||||||
|
if status.total_count.unwrap_or(0) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(match status.state? {
|
||||||
|
CommitStatusState::Success | CommitStatusState::Skipped => CiState::Success,
|
||||||
|
CommitStatusState::Pending | CommitStatusState::Warning => CiState::Pending,
|
||||||
|
CommitStatusState::Failure | CommitStatusState::Error => CiState::Failure,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(
|
||||||
|
repo: &str,
|
||||||
|
pr: &PullRequest,
|
||||||
|
rules: &[BranchProtection],
|
||||||
|
reviews: &[PullReview],
|
||||||
|
status: &CombinedStatus,
|
||||||
|
) -> Option<PrReportRow> {
|
||||||
|
let base_branch = pr.base.as_ref()?.r#ref.clone().unwrap_or_default();
|
||||||
|
let head = pr.head.as_ref()?;
|
||||||
|
let head_sha = head.sha.clone().unwrap_or_default();
|
||||||
|
let protection = required_approvals_for(rules, &base_branch);
|
||||||
|
let required_approvals = protection.unwrap_or(0);
|
||||||
|
let tally = tally_reviews(reviews, &head_sha);
|
||||||
|
let logins = |users: &Option<Vec<forgejo_api::structs::User>>| -> Vec<String> {
|
||||||
|
users
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|u| u.login.clone())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
Some(PrReportRow {
|
||||||
|
repo: repo.to_owned(),
|
||||||
|
number: pr.number?,
|
||||||
|
title: pr.title.clone().unwrap_or_default(),
|
||||||
|
html_url: pr.html_url.as_ref().map(ToString::to_string),
|
||||||
|
author: pr.user.as_ref().and_then(|u| u.login.clone()),
|
||||||
|
labels: pr
|
||||||
|
.labels
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|l| l.name.clone())
|
||||||
|
.collect(),
|
||||||
|
assignees: logins(&pr.assignees),
|
||||||
|
requested_reviewers: logins(&pr.requested_reviewers),
|
||||||
|
base_branch,
|
||||||
|
head_branch: head.r#ref.clone().unwrap_or_default(),
|
||||||
|
created_unix: pr.created_at.as_ref().map(|t| t.unix_timestamp()),
|
||||||
|
updated_unix: pr.updated_at.as_ref().map(|t| t.unix_timestamp()),
|
||||||
|
wip: pr.draft.unwrap_or(false),
|
||||||
|
has_conflicts: pr.mergeable == Some(false),
|
||||||
|
targets_protected_branch: protection.is_some(),
|
||||||
|
required_approvals,
|
||||||
|
approvals: tally.approvals,
|
||||||
|
changes_requested: tally.changes_requested,
|
||||||
|
ci: ci_state(status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// Every open pull request in `owner/repo`, resolved for the PR report.
|
||||||
|
///
|
||||||
|
/// The reviews and the commit statuses are one call per PR, run with
|
||||||
|
/// bounded concurrency like [`Client::issue_report`]'s dependency
|
||||||
|
/// lookups; the branch protection rules are one call for the repo.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// The forge refused or did not answer one of the calls.
|
||||||
|
pub async fn pr_report(&self, owner: &str, repo: &str) -> Result<Vec<PrReportRow>> {
|
||||||
|
const CONCURRENCY: usize = 8;
|
||||||
|
|
||||||
|
let query = RepoListPullRequestsQuery {
|
||||||
|
state: Some(RepoListPullRequestsQueryState::Open),
|
||||||
|
sort: None,
|
||||||
|
milestone: None,
|
||||||
|
labels: None,
|
||||||
|
poster: None,
|
||||||
|
base: None,
|
||||||
|
head: None,
|
||||||
|
};
|
||||||
|
let pulls = self
|
||||||
|
.api
|
||||||
|
.call(|api| {
|
||||||
|
Box::pin(
|
||||||
|
api.repo_list_pull_requests(owner, repo, query.clone())
|
||||||
|
.all(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("list open pull requests in {owner}/{repo}"))?;
|
||||||
|
if pulls.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let rules = self
|
||||||
|
.api
|
||||||
|
.call(|api| api.repo_list_branch_protection(owner, repo).into_future())
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("list branch protections of {owner}/{repo}"))?;
|
||||||
|
|
||||||
|
let full_name = format!("{owner}/{repo}");
|
||||||
|
let rules = &rules;
|
||||||
|
let full_name = &full_name;
|
||||||
|
let mut rows: Vec<PrReportRow> = futures_util::stream::iter(pulls)
|
||||||
|
.map(|pr| async move {
|
||||||
|
let Some(number) = pr.number else {
|
||||||
|
return Ok::<_, anyhow::Error>(None);
|
||||||
|
};
|
||||||
|
let head_sha = pr.head.as_ref().and_then(|h| h.sha.clone());
|
||||||
|
let reviews = self
|
||||||
|
.api
|
||||||
|
.call(|api| Box::pin(api.repo_list_pull_reviews(owner, repo, number).all()))
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("list reviews of {owner}/{repo}#{number}"))?;
|
||||||
|
let status = match head_sha {
|
||||||
|
Some(sha) => {
|
||||||
|
self.api
|
||||||
|
.call(|api| {
|
||||||
|
api.repo_get_combined_status_by_ref(owner, repo, &sha)
|
||||||
|
.into_future()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.with_context(|| {
|
||||||
|
format!("read the statuses of {owner}/{repo}#{number}")
|
||||||
|
})?
|
||||||
|
.1
|
||||||
|
}
|
||||||
|
None => CombinedStatus {
|
||||||
|
commit_url: None,
|
||||||
|
repository: None,
|
||||||
|
sha: None,
|
||||||
|
state: None,
|
||||||
|
statuses: None,
|
||||||
|
total_count: Some(0),
|
||||||
|
url: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(row(full_name, &pr, rules, &reviews, &status))
|
||||||
|
})
|
||||||
|
.buffer_unordered(CONCURRENCY)
|
||||||
|
.try_filter_map(|row| async move { Ok(row) })
|
||||||
|
.try_collect()
|
||||||
|
.await?;
|
||||||
|
rows.sort_by_key(|r| std::cmp::Reverse(r.number));
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The PR report of every repo with an open pull request. A repo whose
|
||||||
|
/// report fails is logged and skipped, as [`Client::issue_report_all`]
|
||||||
|
/// does: one flaky repo should not blank out the others' rows.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// The forge refused to list the repos.
|
||||||
|
pub async fn pr_report_all(&self) -> Result<Vec<PrReportRow>> {
|
||||||
|
const CONCURRENCY: usize = 4;
|
||||||
|
|
||||||
|
let repos = self.list_repos_with_open_pulls().await?;
|
||||||
|
let rows: Vec<Vec<PrReportRow>> = futures_util::stream::iter(repos)
|
||||||
|
.map(|full_name| async move {
|
||||||
|
let Some((owner, repo)) = full_name.split_once('/') else {
|
||||||
|
tracing::warn!(%full_name, "pr_report_all: repo full_name has no '/'");
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
match self.pr_report(owner, repo).await {
|
||||||
|
Ok(rows) => rows,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
%full_name,
|
||||||
|
"pr_report_all: one repo's report failed, skipping it"
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.buffer_unordered(CONCURRENCY)
|
||||||
|
.collect()
|
||||||
|
.await;
|
||||||
|
Ok(rows.into_iter().flatten().collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rule(name: &str, approvals: i64) -> BranchProtection {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"rule_name": name,
|
||||||
|
"required_approvals": approvals,
|
||||||
|
}))
|
||||||
|
.expect("a branch protection")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn review(login: &str, state: &str, commit: &str, flags: &[(&str, bool)]) -> PullReview {
|
||||||
|
let mut v = serde_json::json!({
|
||||||
|
"user": { "login": login },
|
||||||
|
"state": state,
|
||||||
|
"commit_id": commit,
|
||||||
|
"official": true,
|
||||||
|
});
|
||||||
|
for (k, b) in flags {
|
||||||
|
v[*k] = serde_json::json!(b);
|
||||||
|
}
|
||||||
|
serde_json::from_value(v).expect("a review")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pattern_names_a_branch() {
|
||||||
|
assert!(glob_matches("main", "main"));
|
||||||
|
assert!(!glob_matches("main", "main2"));
|
||||||
|
assert!(glob_matches("release/*", "release/1.0"));
|
||||||
|
assert!(!glob_matches("release/*", "release/1.0/hotfix"));
|
||||||
|
assert!(glob_matches("release/**", "release/1.0/hotfix"));
|
||||||
|
assert!(glob_matches("*", "main"));
|
||||||
|
assert!(!glob_matches("*", "iris/foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_first_matching_rule_sets_the_approvals() {
|
||||||
|
let rules = [rule("main", 1), rule("*", 2)];
|
||||||
|
assert_eq!(required_approvals_for(&rules, "main"), Some(1));
|
||||||
|
assert_eq!(required_approvals_for(&rules, "dev"), Some(2));
|
||||||
|
assert_eq!(required_approvals_for(&rules, "iris/feature"), None);
|
||||||
|
assert_eq!(required_approvals_for(&[], "main"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_of_the_head_counts_once_per_reviewer() {
|
||||||
|
let reviews = [
|
||||||
|
review("argus", "APPROVED", "h1", &[]),
|
||||||
|
review("mara", "APPROVED", "h1", &[]),
|
||||||
|
review("mara", "COMMENT", "h1", &[]),
|
||||||
|
];
|
||||||
|
let t = tally_reviews(&reviews, "h1");
|
||||||
|
assert_eq!(
|
||||||
|
t,
|
||||||
|
Tally {
|
||||||
|
approvals: 2,
|
||||||
|
changes_requested: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_old_stale_dismissed_or_unofficial_approval_does_not_count() {
|
||||||
|
for (r, why) in [
|
||||||
|
(review("a", "APPROVED", "old", &[]), "of an older head"),
|
||||||
|
(review("a", "APPROVED", "h1", &[("stale", true)]), "stale"),
|
||||||
|
(
|
||||||
|
review("a", "APPROVED", "h1", &[("dismissed", true)]),
|
||||||
|
"dismissed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
review("a", "APPROVED", "h1", &[("official", false)]),
|
||||||
|
"not official",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(tally_reviews(&[r], "h1").approvals, 0, "{why}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_later_review_replaces_an_earlier_decision() {
|
||||||
|
let reviews = [
|
||||||
|
review("argus", "REQUEST_CHANGES", "h1", &[]),
|
||||||
|
review("argus", "APPROVED", "h1", &[]),
|
||||||
|
];
|
||||||
|
let t = tally_reviews(&reviews, "h1");
|
||||||
|
assert_eq!((t.approvals, t.changes_requested), (1, 0));
|
||||||
|
|
||||||
|
let reviews = [
|
||||||
|
review("argus", "APPROVED", "h1", &[]),
|
||||||
|
review("argus", "REQUEST_CHANGES", "h1", &[]),
|
||||||
|
];
|
||||||
|
let t = tally_reviews(&reviews, "h1");
|
||||||
|
assert_eq!((t.approvals, t.changes_requested), (0, 1));
|
||||||
|
|
||||||
|
let reviews = [review(
|
||||||
|
"argus",
|
||||||
|
"REQUEST_CHANGES",
|
||||||
|
"h1",
|
||||||
|
&[("dismissed", true)],
|
||||||
|
)];
|
||||||
|
assert_eq!(tally_reviews(&reviews, "h1").changes_requested, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_commit_statuses_read_as_one_state() {
|
||||||
|
let status = |state: Option<CommitStatusState>, n: i64| CombinedStatus {
|
||||||
|
commit_url: None,
|
||||||
|
repository: None,
|
||||||
|
sha: None,
|
||||||
|
state,
|
||||||
|
statuses: None,
|
||||||
|
total_count: Some(n),
|
||||||
|
url: None,
|
||||||
|
};
|
||||||
|
assert_eq!(ci_state(&status(None, 0)), None);
|
||||||
|
assert_eq!(
|
||||||
|
ci_state(&status(Some(CommitStatusState::Pending), 0)),
|
||||||
|
None,
|
||||||
|
"no statuses is not pending"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ci_state(&status(Some(CommitStatusState::Success), 3)),
|
||||||
|
Some(CiState::Success)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ci_state(&status(Some(CommitStatusState::Error), 3)),
|
||||||
|
Some(CiState::Failure)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ci_state(&status(Some(CommitStatusState::Pending), 3)),
|
||||||
|
Some(CiState::Pending)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_row_resolves_protection_approvals_and_conflicts() {
|
||||||
|
let pr: PullRequest = serde_json::from_value(serde_json::json!({
|
||||||
|
"number": 7,
|
||||||
|
"title": "WIP: a thing",
|
||||||
|
"draft": true,
|
||||||
|
"mergeable": false,
|
||||||
|
"user": { "login": "iris" },
|
||||||
|
"labels": [{ "name": "type/chore" }],
|
||||||
|
"assignees": [{ "login": "iris" }],
|
||||||
|
"requested_reviewers": [{ "login": "argus" }],
|
||||||
|
"base": { "ref": "main" },
|
||||||
|
"head": { "ref": "iris/thing", "sha": "h1" },
|
||||||
|
"created_at": "2026-11-07T08:00:00Z",
|
||||||
|
"updated_at": "2026-11-07T09:00:00Z",
|
||||||
|
}))
|
||||||
|
.expect("a pull request");
|
||||||
|
let status: CombinedStatus = serde_json::from_value(serde_json::json!({
|
||||||
|
"state": "success", "total_count": 2
|
||||||
|
}))
|
||||||
|
.expect("a combined status");
|
||||||
|
let reviews = [review("argus", "APPROVED", "h1", &[])];
|
||||||
|
let r = row("o/r", &pr, &[rule("main", 2)], &reviews, &status).expect("a row");
|
||||||
|
assert_eq!((r.number, r.wip, r.has_conflicts), (7, true, true));
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
r.targets_protected_branch,
|
||||||
|
r.required_approvals,
|
||||||
|
r.approvals
|
||||||
|
),
|
||||||
|
(true, 2, 1)
|
||||||
|
);
|
||||||
|
assert_eq!(r.ci, Some(CiState::Success));
|
||||||
|
assert_eq!(r.author.as_deref(), Some("iris"));
|
||||||
|
assert_eq!(r.requested_reviewers, ["argus"]);
|
||||||
|
assert_eq!(r.updated_unix, Some(1_794_042_000));
|
||||||
|
|
||||||
|
// The same PR into a branch no rule names has nothing to approve.
|
||||||
|
let r = row("o/r", &pr, &[], &reviews, &status).expect("a row");
|
||||||
|
assert_eq!(
|
||||||
|
(r.targets_protected_branch, r.required_approvals),
|
||||||
|
(false, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,7 @@ mod notify_flows;
|
||||||
mod notify_router_api;
|
mod notify_router_api;
|
||||||
mod observability_grant;
|
mod observability_grant;
|
||||||
mod otel_http_client;
|
mod otel_http_client;
|
||||||
|
mod pr_report;
|
||||||
mod proxy_credential;
|
mod proxy_credential;
|
||||||
mod pulls;
|
mod pulls;
|
||||||
mod queue_identity;
|
mod queue_identity;
|
||||||
|
|
|
||||||
149
swarm-controller/src/pr_report.rs
Normal file
149
swarm-controller/src/pr_report.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
//! The swarm UI's PR report: every open pull request of the forge, or of one
|
||||||
|
//! repo, with the attributes a reviewer or merger filters on already
|
||||||
|
//! resolved. The logic is [`forge::Client::pr_report`]; this module is the
|
||||||
|
//! HTTP surface over it, the same shape as [`crate::issue_report`]. The repo
|
||||||
|
//! dropdown's source is `GET /api/pulls/repos`.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::forge;
|
||||||
|
use crate::issue_report::issue_report_error_status;
|
||||||
|
use crate::routes::{AppState, error_problem};
|
||||||
|
|
||||||
|
/// The 503 detail when the forge did not answer within
|
||||||
|
/// [`forge::FORGE_DEADLINE`].
|
||||||
|
const DEADLINE_DETAIL: &str = "the forge did not answer within the deadline";
|
||||||
|
|
||||||
|
/// Every open pull request across every repo that has one.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/pr-report",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "every open pull request, newest number first within a repo", body = Vec<forge::pr_report::PrReportRow>),
|
||||||
|
(status = 503, description = "no forge is configured on this host, the report could not be built, or the forge did not answer within two minutes (problem+json)", body = String),
|
||||||
|
),
|
||||||
|
tag = "pulls"
|
||||||
|
)]
|
||||||
|
pub async fn get_pr_report_all(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<forge::pr_report::PrReportRow>>, problem_details::ProblemDetails> {
|
||||||
|
let Some(client) = state.forge.get() else {
|
||||||
|
return Err(error_problem(
|
||||||
|
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"no forge is configured on this host",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let Some(built) = forge::within_forge_deadline("PR report", client.pr_report_all()).await
|
||||||
|
else {
|
||||||
|
return Err(error_problem(
|
||||||
|
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
DEADLINE_DETAIL,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
match built {
|
||||||
|
Ok(rows) => Ok(Json(rows)),
|
||||||
|
Err(e) => {
|
||||||
|
let detail = format!("{e:#}");
|
||||||
|
tracing::warn!(error = %detail, "building the all-repos PR report failed");
|
||||||
|
Err(error_problem(
|
||||||
|
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
&detail,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The open pull requests of `{org}/{repo}`. A 404 from the forge for the
|
||||||
|
/// repo itself is a 404 here; anything else stays 503, as for the issue
|
||||||
|
/// report ([`issue_report_error_status`]).
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/repos/{org}/{repo}/pr-report",
|
||||||
|
params(
|
||||||
|
("org" = String, Path, description = "repo owner (user or org)"),
|
||||||
|
("repo" = String, Path, description = "repo name"),
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "every open pull request in this repo", body = Vec<forge::pr_report::PrReportRow>),
|
||||||
|
(status = 404, description = "no such repo (problem+json)", body = String),
|
||||||
|
(status = 503, description = "no forge is configured on this host, the report could not be built, or the forge did not answer within two minutes (problem+json)", body = String),
|
||||||
|
),
|
||||||
|
tag = "pulls"
|
||||||
|
)]
|
||||||
|
pub async fn get_pr_report(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((org, repo)): Path<(String, String)>,
|
||||||
|
) -> Result<Json<Vec<forge::pr_report::PrReportRow>>, problem_details::ProblemDetails> {
|
||||||
|
let Some(client) = state.forge.get() else {
|
||||||
|
return Err(error_problem(
|
||||||
|
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"no forge is configured on this host",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let Some(built) =
|
||||||
|
forge::within_forge_deadline("repo PR report", client.pr_report(&org, &repo)).await
|
||||||
|
else {
|
||||||
|
return Err(error_problem(
|
||||||
|
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
DEADLINE_DETAIL,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
match built {
|
||||||
|
Ok(rows) => Ok(Json(rows)),
|
||||||
|
Err(e) => {
|
||||||
|
let status = issue_report_error_status(&e);
|
||||||
|
if status == axum::http::StatusCode::NOT_FOUND {
|
||||||
|
Err(error_problem(status, &format!("{org}/{repo} not found")))
|
||||||
|
} else {
|
||||||
|
let detail = format!("{e:#}");
|
||||||
|
tracing::warn!(error = %detail, %org, %repo, "building PR report failed");
|
||||||
|
Err(error_problem(status, &detail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Each route against a forge that accepts the connection and never
|
||||||
|
/// answers: 503 at the deadline. The paused clock jumps to the deadline
|
||||||
|
/// once nothing else can run, so this takes no wall time.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn every_route_is_503_when_the_forge_never_answers() {
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use tower::ServiceExt as _;
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
|
||||||
|
for uri in ["/api/pr-report", "/api/repos/o/r/pr-report"] {
|
||||||
|
let (state, _) = crate::routes::tests::state_with_roster();
|
||||||
|
assert!(
|
||||||
|
state
|
||||||
|
.forge
|
||||||
|
.set(std::sync::Arc::new(forge::Client::for_test_forge(
|
||||||
|
url.clone()
|
||||||
|
)))
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
let start = tokio::time::Instant::now();
|
||||||
|
let req = Request::get(uri).body(Body::empty()).expect("a request");
|
||||||
|
let res = crate::startup::build_app(state)
|
||||||
|
.oneshot(req)
|
||||||
|
.await
|
||||||
|
.expect("infallible");
|
||||||
|
assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
|
||||||
|
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = String::from_utf8_lossy(&bytes);
|
||||||
|
assert!(body.contains(super::DEADLINE_DETAIL), "{uri}: {body}");
|
||||||
|
assert_eq!(start.elapsed(), forge::FORGE_DEADLINE, "{uri}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,9 +31,9 @@ use crate::{
|
||||||
agent_permissions, agent_renewal, agent_state_stream, agent_status, agents_md, alerts,
|
agent_permissions, agent_renewal, agent_state_stream, agent_status, agents_md, alerts,
|
||||||
archetypes, auth, backend_env, builder, claude_login, config_drift, config_pr, egress_allow,
|
archetypes, auth, backend_env, builder, claude_login, config_drift, config_pr, egress_allow,
|
||||||
egress_deny, forge, forge_account, github_account, gitlab_account, inference, issue_report,
|
egress_deny, forge, forge_account, github_account, gitlab_account, inference, issue_report,
|
||||||
linked_accounts, matrix_account, message_stream, notify_flows, notify_router_api, pulls,
|
linked_accounts, matrix_account, message_stream, notify_flows, notify_router_api, pr_report,
|
||||||
queue_identity, review_classifier, schedules, status, subagent_term, term_stream, wanted,
|
pulls, queue_identity, review_classifier, schedules, status, subagent_term, term_stream,
|
||||||
webhook,
|
wanted, webhook,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
|
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
|
||||||
|
|
@ -80,7 +80,7 @@ pub(crate) fn socket_path() -> PathBuf {
|
||||||
(name = "egress", description = "the destinations agents may reach through their hive's egress proxy"),
|
(name = "egress", description = "the destinations agents may reach through their hive's egress proxy"),
|
||||||
(name = "notify", description = "the notification router's flows: built-ins switched off, and custom flows"),
|
(name = "notify", description = "the notification router's flows: built-ins switched off, and custom flows"),
|
||||||
(name = "review-classifier", description = "trying review classifications against a PR (swarm-ui's try-out page), and the classifier's swarm-wide settings"),
|
(name = "review-classifier", description = "trying review classifications against a PR (swarm-ui's try-out page), and the classifier's swarm-wide settings"),
|
||||||
(name = "pulls", description = "open pull requests, for the try-out page's dropdowns"),
|
(name = "pulls", description = "open pull requests: the try-out page's dropdowns and the PR report"),
|
||||||
(name = "alerts", description = "the swarm Grafana's current alerts (swarm-ui's alerts page)"),
|
(name = "alerts", description = "the swarm Grafana's current alerts (swarm-ui's alerts page)"),
|
||||||
(name = "builder", description = "the swarm builder's finished builds of agent config PRs, which their commit statuses link to"),
|
(name = "builder", description = "the swarm builder's finished builds of agent config PRs, which their commit statuses link to"),
|
||||||
)
|
)
|
||||||
|
|
@ -667,6 +667,8 @@ pub(crate) fn build_app(state: AppState) -> axum::Router {
|
||||||
.routes(routes!(issue_report::get_repos))
|
.routes(routes!(issue_report::get_repos))
|
||||||
.routes(routes!(issue_report::get_issue_report_all))
|
.routes(routes!(issue_report::get_issue_report_all))
|
||||||
.routes(routes!(issue_report::get_issue_report))
|
.routes(routes!(issue_report::get_issue_report))
|
||||||
|
.routes(routes!(pr_report::get_pr_report_all))
|
||||||
|
.routes(routes!(pr_report::get_pr_report))
|
||||||
.routes(routes!(pulls::get_pull_repos))
|
.routes(routes!(pulls::get_pull_repos))
|
||||||
.routes(routes!(pulls::get_pulls))
|
.routes(routes!(pulls::get_pulls))
|
||||||
.routes(routes!(review_classifier::post_try_out))
|
.routes(routes!(review_classifier::post_try_out))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue