//! 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, pub author: Option, pub labels: Vec, pub assignees: Vec, pub requested_reviewers: Vec, pub base_branch: String, pub head_branch: String, pub created_unix: Option, pub updated_unix: Option, /// Lines the PR adds and removes, as the forge counts them. pub additions: Option, pub deletions: Option, /// 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, } /// Whether `branch` is named by a branch protection pattern. The forge reads a /// rule's name as a glob with `/` as the separator: `*` and `?` stop at a `/`, /// `**` does not, `[a-z]` and `[!a-z]` are classes, `{a,b}` is a choice and a /// backslash escapes the next character. fn glob_matches(pattern: &str, branch: &str) -> bool { let branch: Vec = branch.chars().collect(); expand_choices(pattern).iter().any(|p| { let p: Vec = p.chars().collect(); glob_run(&p, &branch) }) } /// `pattern` with each `{a,b}` choice written out: `v{1,2}` is `v1` and `v2`. /// A `{` with no closing `}` is itself. fn expand_choices(pattern: &str) -> Vec { let chars: Vec = pattern.chars().collect(); let mut escaped = false; let mut open = None; for (i, &c) in chars.iter().enumerate() { match c { _ if escaped => escaped = false, '\\' => escaped = true, '{' => { open = Some(i); break; } _ => {} } } let Some(open) = open else { return vec![pattern.to_owned()]; }; // The matching `}` and the commas between, at this nesting only. let (mut depth, mut escaped) = (0_usize, false); let mut cuts = vec![open]; for (i, &c) in chars.iter().enumerate().skip(open) { match c { _ if escaped => escaped = false, '\\' => escaped = true, '{' => depth += 1, ',' if depth == 1 => cuts.push(i), '}' => { depth -= 1; if depth == 0 { cuts.push(i); let text = |from: usize, to: usize| chars[from..to].iter().collect::(); let (head, tail) = (text(0, open), text(i + 1, chars.len())); return cuts .windows(2) .flat_map(|w| { expand_choices(&format!("{head}{}{tail}", text(w[0] + 1, w[1]))) }) .collect(); } } _ => {} } } vec![pattern.to_owned()] } /// Whether the choice-free pattern `p` matches all of `s`. fn glob_run(p: &[char], s: &[char]) -> bool { let Some((&c, rest)) = p.split_first() else { return s.is_empty(); }; match c { '*' => { if let Some(rest) = rest.strip_prefix(&['*']) { // `**`: any run, slashes included. (0..=s.len()).any(|i| glob_run(rest, &s[i..])) } else { // `*`: any run up to the next slash. let reach = s.iter().position(|&d| d == '/').unwrap_or(s.len()); (0..=reach).any(|i| glob_run(rest, &s[i..])) } } '?' => s .split_first() .is_some_and(|(&d, tail)| d != '/' && glob_run(rest, tail)), '[' => match glob_class(rest) { Some((members, negated, after)) => s.split_first().is_some_and(|(&d, tail)| { d != '/' && members.contains(d) != negated && glob_run(after, tail) }), // No closing `]`: a literal bracket. None => s .split_first() .is_some_and(|(&d, tail)| d == '[' && glob_run(rest, tail)), }, '\\' if !rest.is_empty() => s .split_first() .is_some_and(|(&d, tail)| d == rest[0] && glob_run(&rest[1..], tail)), _ => s .split_first() .is_some_and(|(&d, tail)| c == d && glob_run(rest, tail)), } } /// What a `[` class holds: its members, whether it is negated (`[!a]`), and /// the pattern after its `]`. `None` for a class with no `]`. fn glob_class(after_bracket: &[char]) -> Option<(Members, bool, &[char])> { let (negated, body) = match after_bracket.split_first() { Some((&'!', body)) => (true, body), _ => (false, after_bracket), }; let end = body.iter().position(|&c| c == ']')?; let mut members = Members::default(); let mut i = 0; while i < end { if i + 2 < end && body[i + 1] == '-' { members.ranges.push((body[i], body[i + 2])); i += 3; } else { members.ranges.push((body[i], body[i])); i += 1; } } Some((members, negated, &body[end + 1..])) } /// The characters and ranges a `[` class names. #[derive(Default)] struct Members { ranges: Vec<(char, char)>, } impl Members { fn contains(&self, c: char) -> bool { self.ranges.iter().any(|&(lo, hi)| (lo..=hi).contains(&c)) } } /// 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 { 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 { 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 { 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 { 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()), additions: pr.additions, deletions: pr.deletions, 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> { 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 = 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> { const CONCURRENCY: usize = 4; let repos = self.list_repos_with_open_pulls().await?; let rows: Vec> = 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")); } /// Forgejo reads a rule's name as a glob with `/` as its separator, so /// `?`, `[..]` and `{a,b}` name branches too. A rule read as naming none /// would show a PR as needing no approvals. #[test] fn a_pattern_may_use_choices_classes_and_a_single_character() { for (pattern, branch, matches) in [ ("{main,release}", "main", true), ("{main,release}", "release", true), ("{main,release}", "dev", false), ("{main,release}", "mainx", false), ("release/{1,2}.*", "release/2.0", true), ("release/{1,2}.*", "release/3.0", false), ("{a,{b,c}}/x", "c/x", true), ("v?", "v1", true), ("v?", "v10", false), ("v?", "v/", false), ("v[0-9]", "v7", true), ("v[0-9]", "va", false), ("v[!0-9]", "va", true), ("v[!0-9]", "v7", false), ("v[abc]", "vb", true), (r"v\*", "v*", true), (r"v\*", "vx", false), ("a[b", "a[b", true), ("{a", "{a", true), ("**/main", "x/y/main", true), ] { assert_eq!( glob_matches(pattern, branch), matches, "{pattern} on {branch}" ); } } #[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, 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", "additions": 120, "deletions": 8, })) .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)); assert_eq!((r.additions, r.deletions), (Some(120), Some(8))); // 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) ); } }