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
12 changed files with 1132 additions and 8 deletions

View file

@ -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
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
The builds sit in a card under the job graph on the **jobs** tab. While the graph

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

View file

@ -56,6 +56,7 @@ mod meta_repo;
pub mod objects;
pub mod operator_profiles;
pub(crate) mod pr_comment;
pub mod pr_report;
pub mod pull_diff;
pub mod review_request;
pub mod runner_token;

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

View file

@ -70,6 +70,7 @@ mod notify_flows;
mod notify_router_api;
mod observability_grant;
mod otel_http_client;
mod pr_report;
mod proxy_credential;
mod pulls;
mod queue_identity;

View 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}");
}
}
}

View file

@ -31,9 +31,9 @@ use crate::{
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,
egress_deny, forge, forge_account, github_account, gitlab_account, inference, issue_report,
linked_accounts, matrix_account, message_stream, notify_flows, notify_router_api, pulls,
queue_identity, review_classifier, schedules, status, subagent_term, term_stream, wanted,
webhook,
linked_accounts, matrix_account, message_stream, notify_flows, notify_router_api, pr_report,
pulls, queue_identity, review_classifier, schedules, status, subagent_term, term_stream,
wanted, webhook,
};
/// 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 = "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 = "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 = "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_issue_report_all))
.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_pulls))
.routes(routes!(review_classifier::post_try_out))