Compare commits

..
4 changed files with 26 additions and 99 deletions

View file

@ -121,43 +121,18 @@ pub(crate) fn pct_encode(s: &str) -> String {
out out
} }
/// One reviewer's latest verdict on a PR, plus forgejo's `stale` / /// Latest non-comment review verdict per reviewer on a PR, as
/// `dismissed` bits. /// `(login, state)`. Reviews come oldest-first, so a later verdict from
/// /// the same user supersedes an earlier one; `COMMENT` / `PENDING`
/// `stale` is set by forgejo when the PR head commit changed after this /// reviews carry no verdict and are skipped. Shared by `pr-status` (for
/// review was submitted (branch protection then wants a fresh review); /// its health view + readiness verdict) and `pr-merge` (for its
/// `dismissed` is set when the review was explicitly dismissed. Either way /// pre-merge changes-requested gate) so the verdict semantics stay in
/// the review no longer applies to the current head even though its `state` /// one place.
/// string still reads `APPROVED` / `REQUEST_CHANGES` — so surfacing them pub(crate) fn latest_reviews(
/// stops the CLI from reporting a no-longer-valid review as still-good, and client: &Client,
/// [`ReviewInfo::superseded`] rolls both into one "doesn't count" check. repo: &str,
pub(crate) struct ReviewInfo { pr: u64,
pub login: String, ) -> Result<Vec<(String, String)>> {
pub state: String,
pub stale: bool,
pub dismissed: bool,
}
impl ReviewInfo {
/// True when the review no longer applies to the current head — stale
/// (head moved) or dismissed. Such a verdict neither blocks a merge nor
/// counts as a fresh approval.
pub(crate) fn superseded(&self) -> bool {
self.stale || self.dismissed
}
}
/// Latest non-comment review per reviewer on a PR. Reviews come
/// oldest-first, so a later verdict from the same user supersedes an
/// earlier one; `COMMENT` / `PENDING` reviews carry no verdict and are
/// skipped. Shared by `pr-status` (health view + readiness verdict) and
/// `pr-merge` (pre-merge changes-requested gate) so the verdict semantics
/// stay in one place.
///
/// # Errors
///
/// Propagates the forge API errors from listing the PR's reviews.
pub(crate) fn latest_reviews(client: &Client, repo: &str, pr: u64) -> Result<Vec<ReviewInfo>> {
let (owner, name) = crate::client::split_repo(repo)?; let (owner, name) = crate::client::split_repo(repo)?;
let pr = index(pr)?; let pr = index(pr)?;
// Paginate (50/page, 10-page runaway cap — same ceiling the raw // Paginate (50/page, 10-page runaway cap — same ceiling the raw
@ -176,7 +151,7 @@ pub(crate) fn latest_reviews(client: &Client, repo: &str, pr: u64) -> Result<Vec
break; break;
} }
} }
let mut latest: Vec<ReviewInfo> = Vec::new(); let mut latest: Vec<(String, String)> = Vec::new();
for r in &reviews { for r in &reviews {
let Some(login) = r.user.as_ref().and_then(|u| u.login.as_deref()) else { let Some(login) = r.user.as_ref().and_then(|u| u.login.as_deref()) else {
continue; continue;
@ -185,19 +160,10 @@ pub(crate) fn latest_reviews(client: &Client, repo: &str, pr: u64) -> Result<Vec
if st == "COMMENT" || st == "PENDING" || st.is_empty() { if st == "COMMENT" || st == "PENDING" || st.is_empty() {
continue; continue;
} }
let stale = r.stale.unwrap_or(false); if let Some(slot) = latest.iter_mut().find(|(l, _)| l == login) {
let dismissed = r.dismissed.unwrap_or(false); st.clone_into(&mut slot.1);
if let Some(slot) = latest.iter_mut().find(|info| info.login == login) {
st.clone_into(&mut slot.state);
slot.stale = stale;
slot.dismissed = dismissed;
} else { } else {
latest.push(ReviewInfo { latest.push((login.to_owned(), st.to_owned()));
login: login.to_owned(),
state: st.to_owned(),
stale,
dismissed,
});
} }
} }
Ok(latest) Ok(latest)

View file

@ -157,13 +157,11 @@ fn check_ready(client: &Client, repo: &str, number: u64, pull: &PullRequest) ->
// Block only on reviewers whose *current* verdict requests changes // Block only on reviewers whose *current* verdict requests changes
// (latest-per-reviewer, so a later APPROVED clears an earlier // (latest-per-reviewer, so a later APPROVED clears an earlier
// REQUEST_CHANGES). A superseded REQUEST_CHANGES — stale (older head) or // REQUEST_CHANGES). Shares the verdict logic with `pr-status`.
// dismissed — no longer applies, so it doesn't block. Shares the verdict
// logic with `pr-status`.
let blockers: Vec<String> = super::latest_reviews(client, repo, number)? let blockers: Vec<String> = super::latest_reviews(client, repo, number)?
.into_iter() .into_iter()
.filter(|r| r.state == "REQUEST_CHANGES" && !r.superseded()) .filter(|(_, st)| st == "REQUEST_CHANGES")
.map(|r| r.login) .map(|(login, _)| login)
.collect(); .collect();
if !blockers.is_empty() { if !blockers.is_empty() {
bail!( bail!(

View file

@ -135,13 +135,6 @@ fn list_reviews_json(client: &Client, number: u64, reviews: &[Value]) -> Result<
json!({ json!({
"id": r.get("id"), "id": r.get("id"),
"state": r.get("state"), "state": r.get("state"),
// forgejo's staleness bits: `stale` = head moved since the
// review (branch protection wants a fresh one); `dismissed`
// = explicitly dismissed. A stale/dismissed APPROVED no
// longer satisfies the merge gate despite `state` reading
// APPROVED — surface them so callers don't over-trust it.
"stale": r.get("stale"),
"dismissed": r.get("dismissed"),
"user": r.get("user").and_then(|u| u.get("login")), "user": r.get("user").and_then(|u| u.get("login")),
"body": r.get("body"), "body": r.get("body"),
"comments_count": r.get("comments_count"), "comments_count": r.get("comments_count"),
@ -168,18 +161,7 @@ fn list_reviews_text(client: &Client, number: u64, reviews: &[Value]) {
.unwrap_or("?"); .unwrap_or("?");
let state = r.get("state").and_then(Value::as_str).unwrap_or("?"); let state = r.get("state").and_then(Value::as_str).unwrap_or("?");
let body = r.get("body").and_then(Value::as_str).unwrap_or("").trim(); let body = r.get("body").and_then(Value::as_str).unwrap_or("").trim();
// Flag reviews forgejo considers no-longer-current so a stale println!("### review by {user} ({state})");
// APPROVED doesn't read as still-satisfying branch protection.
let is_stale = r.get("stale").and_then(Value::as_bool).unwrap_or(false);
let is_dismissed = r.get("dismissed").and_then(Value::as_bool).unwrap_or(false);
let flag = if is_stale {
" [stale — needs re-review]"
} else if is_dismissed {
" [dismissed]"
} else {
""
};
println!("### review by {user} ({state}){flag}");
if !body.is_empty() { if !body.is_empty() {
println!("{body}"); println!("{body}");
} }

View file

@ -120,10 +120,7 @@ fn pr_status(client: &Client, repo: &str, pr: u64) -> Result<()> {
"requested_reviewers": requested, "requested_reviewers": requested,
"reviews": reviews "reviews": reviews
.iter() .iter()
.map(|r| serde_json::json!({ .map(|(l, s)| serde_json::json!({"user": l, "state": s}))
"user": r.login, "state": r.state,
"stale": r.stale, "dismissed": r.dismissed,
}))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
"last_comment": last "last_comment": last
.as_ref() .as_ref()
@ -146,12 +143,9 @@ fn pr_status(client: &Client, repo: &str, pr: u64) -> Result<()> {
} }
// Merge-readiness: CI green, mergeable, and nobody requesting changes. // Merge-readiness: CI green, mergeable, and nobody requesting changes.
// A *superseded* REQUEST_CHANGES — stale (branch moved since) or
// dismissed — no longer applies to the current head, so it doesn't hold
// up the verdict.
let changes_requested = reviews let changes_requested = reviews
.iter() .iter()
.any(|r| r.state == "REQUEST_CHANGES" && !r.superseded()); .any(|(_, verdict)| verdict == "REQUEST_CHANGES");
let ready = ci_state == "success" && mergeable == Some(true) && !changes_requested; let ready = ci_state == "success" && mergeable == Some(true) && !changes_requested;
if ready { if ready {
Ok(()) Ok(())
@ -286,7 +280,7 @@ fn print_pr(
ci_state: &str, ci_state: &str,
ci_statuses: &[Value], ci_statuses: &[Value],
requested: &[String], requested: &[String],
reviews: &[super::ReviewInfo], reviews: &[(String, String)],
last_comment: Option<&(String, String)>, last_comment: Option<&(String, String)>,
) { ) {
println!("PR #{pr}: {title}"); println!("PR #{pr}: {title}");
@ -316,26 +310,13 @@ fn print_pr(
} else { } else {
let rendered: Vec<String> = reviews let rendered: Vec<String> = reviews
.iter() .iter()
.map(|r| { .map(|(login, verdict)| {
let mark = match r.state.as_str() { let mark = match verdict.as_str() {
// A superseded approval (stale/dismissed) no longer
// satisfies branch protection; flag it so the CLI doesn't
// read as still-good.
"APPROVED" if r.superseded() => "⚠️",
"APPROVED" => "", "APPROVED" => "",
"REQUEST_CHANGES" => "", "REQUEST_CHANGES" => "",
_ => "", _ => "",
}; };
let login = &r.login; format!("{mark} {login}: {verdict}")
let state = &r.state;
let suffix = if r.stale {
" (stale — needs re-review on current head)"
} else if r.dismissed {
" (dismissed)"
} else {
""
};
format!("{mark} {login}: {state}{suffix}")
}) })
.collect(); .collect();
println!(" reviews: {}", rendered.join(", ")); println!(" reviews: {}", rendered.join(", "));