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
5 changed files with 1132 additions and 8 deletions
|
|
@ -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;
|
||||
|
|
|
|||
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 observability_grant;
|
||||
mod otel_http_client;
|
||||
mod pr_report;
|
||||
mod proxy_credential;
|
||||
mod pulls;
|
||||
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,
|
||||
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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue