share the standing-review rule between hive-forge and the controller
pr assign-reviewer behaves as before; the latest-review fold and the superseded check move to hive-types::review.
This commit is contained in:
parent
65329fb506
commit
463c5cbfcc
7 changed files with 259 additions and 115 deletions
|
|
@ -346,66 +346,15 @@ fn edit_distance(a: &str, b: &str) -> usize {
|
|||
prev[b.len()]
|
||||
}
|
||||
|
||||
/// One reviewer's latest verdict on a PR, plus forgejo's `stale` /
|
||||
/// `dismissed` bits.
|
||||
///
|
||||
/// `stale` is set by forgejo when the PR head commit changed after this
|
||||
/// review was submitted (branch protection then wants a fresh review);
|
||||
/// `dismissed` is set when the review was explicitly dismissed. Either way
|
||||
/// the review no longer applies to the current head even though its `state`
|
||||
/// string still reads `APPROVED` / `REQUEST_CHANGES` — so surfacing them
|
||||
/// stops the CLI from reporting a no-longer-valid review as still-good, and
|
||||
/// [`ReviewInfo::superseded`] rolls both into one "doesn't count" check.
|
||||
///
|
||||
/// ⚠️ **`stale` here is not forgejo's flag alone.** That flag is eventually
|
||||
/// consistent: seconds after a push it still reports the pre-push answer, so
|
||||
/// a verdict against the previous head reads as current in exactly the window
|
||||
/// where someone runs the CLI right after pushing. [`latest_reviews`] therefore
|
||||
/// ORs it with a direct comparison of the review's own `commit_id` against the
|
||||
/// PR head — the flag is right *eventually*, the comparison is right
|
||||
/// *immediately*, and either alone is worse than both.
|
||||
pub(crate) struct ReviewInfo {
|
||||
pub login: 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this review was submitted against a commit that is no longer the
|
||||
/// PR head — the race-free half of the staleness check.
|
||||
///
|
||||
/// Fails **closed on unknowns**, i.e. "not stale": either side missing means
|
||||
/// we cannot show the head moved, and the cost of guessing wrong in that
|
||||
/// direction is one redundant re-review, where the other direction would
|
||||
/// silently void every verdict on the PR (blocking nothing, but reporting a
|
||||
/// ready PR as unreviewed and inviting a re-request that dismisses a real
|
||||
/// approval).
|
||||
fn reviewed_older_head(reviewed_sha: Option<&str>, head_sha: Option<&str>) -> bool {
|
||||
let (Some(head), Some(reviewed)) = (head_sha, reviewed_sha) else {
|
||||
return false;
|
||||
};
|
||||
// Both emptiness checks matter, and for the same reason: a blank string
|
||||
// is a value the forge sent, not a sha it has. Treating one as real would
|
||||
// make every review compare unequal and mark the whole PR stale — the
|
||||
// direction this whole function exists to avoid.
|
||||
!head.is_empty() && !reviewed.is_empty() && reviewed != head
|
||||
}
|
||||
pub(crate) use hive_types::review::ReviewInfo;
|
||||
|
||||
/// 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.
|
||||
/// skipped. Shared by `pr status` (health view + readiness verdict),
|
||||
/// `pr merge` (pre-merge changes-requested gate) and `pr assign-reviewer`,
|
||||
/// and by swarm-controller's `request-reviewer` sink, through
|
||||
/// [`hive_types::review`], so the verdict semantics stay in one place.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
|
|
@ -439,32 +388,16 @@ pub(crate) fn latest_reviews(client: &Client, repo: &str, pr: u64) -> Result<Vec
|
|||
break;
|
||||
}
|
||||
}
|
||||
let mut latest: Vec<ReviewInfo> = Vec::new();
|
||||
for r in &reviews {
|
||||
let Some(login) = r.user.as_ref().and_then(|u| u.login.as_deref()) else {
|
||||
continue;
|
||||
};
|
||||
let st = r.state.as_deref().unwrap_or("");
|
||||
if st == "COMMENT" || st == "PENDING" || st.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let stale = r.stale.unwrap_or(false)
|
||||
|| reviewed_older_head(r.commit_id.as_deref(), head_sha.as_deref());
|
||||
let dismissed = r.dismissed.unwrap_or(false);
|
||||
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 {
|
||||
latest.push(ReviewInfo {
|
||||
login: login.to_owned(),
|
||||
state: st.to_owned(),
|
||||
stale,
|
||||
dismissed,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(latest)
|
||||
Ok(hive_types::review::latest_reviews(
|
||||
reviews.iter().map(|r| hive_types::review::Listed {
|
||||
login: r.user.as_ref().and_then(|u| u.login.as_deref()),
|
||||
state: r.state.as_deref(),
|
||||
commit_id: r.commit_id.as_deref(),
|
||||
stale: r.stale,
|
||||
dismissed: r.dismissed,
|
||||
}),
|
||||
head_sha.as_deref(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The current dependency list for an issue or PR — each entry names
|
||||
|
|
@ -684,7 +617,7 @@ pub(crate) fn attachment_json(assets: Option<&[Attachment]>) -> Vec<Value> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{NullableVec, pct_encode, refuse_json, reviewed_older_head};
|
||||
use super::{NullableVec, pct_encode, refuse_json};
|
||||
|
||||
#[test]
|
||||
fn refuse_json_bails_with_the_verb_and_reason() {
|
||||
|
|
@ -715,32 +648,6 @@ mod tests {
|
|||
assert_eq!(v.0, vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_on_an_older_commit_is_stale() {
|
||||
// The bug this exists for: forgejo still reports `stale: false` here
|
||||
// in the seconds after a push, so the comparison has to catch it.
|
||||
assert!(reviewed_older_head(Some("81292f14"), Some("f4c47088")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_on_the_current_head_is_not_stale() {
|
||||
assert!(!reviewed_older_head(Some("f4c47088"), Some("f4c47088")));
|
||||
}
|
||||
|
||||
/// Every unknown fails *toward* keeping the verdict. Voiding every review
|
||||
/// on a forge that stopped reporting one of these would be a far louder
|
||||
/// wrong answer than one redundant re-review.
|
||||
#[test]
|
||||
fn unknown_commit_or_head_is_not_stale() {
|
||||
assert!(!reviewed_older_head(None, Some("f4c47088")));
|
||||
assert!(!reviewed_older_head(Some("81292f14"), None));
|
||||
assert!(!reviewed_older_head(Some(""), Some("f4c47088")));
|
||||
// Absent and blank have to behave the same on BOTH sides — a blank
|
||||
// head that counted as real would mark every review on the PR stale.
|
||||
assert!(!reviewed_older_head(Some("81292f14"), Some("")));
|
||||
assert!(!reviewed_older_head(Some(""), Some("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pct_encode_passes_unreserved_through() {
|
||||
// Plain artifact names round-trip verbatim — no performance
|
||||
|
|
|
|||
|
|
@ -55,10 +55,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
// Guard the add path only — withdrawing a request is always safe.
|
||||
// Re-requesting from someone who already reviewed dismisses that review
|
||||
// instead of no-op'ing (see the module doc-comment above).
|
||||
if let Some(existing) = super::latest_reviews(client, client.repo()?, args.number)?
|
||||
.into_iter()
|
||||
.find(|r| r.login == args.user && !r.superseded())
|
||||
{
|
||||
let reviews = super::latest_reviews(client, client.repo()?, args.number)?;
|
||||
if let Some(existing) = hive_types::review::standing(&reviews, &args.user) {
|
||||
println!(
|
||||
"not re-requesting: {} already has a standing {} review on #{} (re-requesting would dismiss it)",
|
||||
args.user, existing.state, args.number
|
||||
|
|
|
|||
Loading…
Reference in a new issue