The labels and reviewers columns had no sort, so "every column sorts" was
false; both now sort on their joined text.
A branch protection rule's name is a glob the forge compiles with `/` as its
separator, which also takes `?`, `[a-z]`, `[!a-z]`, `{a,b}` and a backslash
escape. The report read only `*` and `**`, so a rule such as `{main,release}`
matched nothing and its PRs showed as protected by no rule, needing no
approvals. It now reads all of them.
666 lines
24 KiB
Rust
666 lines
24 KiB
Rust
//! 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>,
|
|
/// Lines the PR adds and removes, as the forge counts them.
|
|
pub additions: Option<i64>,
|
|
pub deletions: 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. 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<char> = branch.chars().collect();
|
|
expand_choices(pattern).iter().any(|p| {
|
|
let p: Vec<char> = 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<String> {
|
|
let chars: Vec<char> = 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::<String>();
|
|
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<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()),
|
|
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<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"));
|
|
}
|
|
|
|
/// 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<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",
|
|
"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)
|
|
);
|
|
}
|
|
}
|