Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: the review classifier reads a diff with 200 lines of context

The classifier read the forge's PR `.diff`, whose context is fixed at 3
lines. A block moved into a glue module lost its enclosing gate (a
`config = lib.mkIf …` 13 to 191 lines up in the misfired moves #5199,
#5200, #5201), so the glue restating that gate read as a new condition.

The controller now builds the diff itself: it lists the PR's changed files,
reads each at the merge base and at the head through the git tree and blob
API with the token it already holds, and diffs them with 200 lines of
context. It sends the forge's `.diff` instead when the PR changes more than
100 files, the forge names no merge base, or the wide diff is over 256 KiB,
and the posted review then says so with the reason.

Fixes #5227
This commit is contained in:
atlas 2026-10-15 06:48:27 +02:00 • committed by mara
commit 2b6d480d9c

View file

@ -30,6 +30,7 @@ use super::settings::{Settings, SettingsSource};
use super::{FileSource, PullSource, RunSink, split_repo};
use crate::AppState;
use crate::agent_renewal::unix_now;
use crate::forge::pull_diff::CONTEXT_LINES;
use crate::forge::{self, classifier_account::LOGIN, classifier_account::Review};
/// The most review runs waiting for a slot.
@ -450,8 +451,15 @@ async fn run(
req: &Requested,
) -> RunRecord {
let started_unix = unix_now();
let (head, outcome) = classify(files, pulls, sink, req).await;
let ruling = review_for(&outcome);
let (head, narrowed, outcome) = classify(files, pulls, sink, req).await;
let mut ruling = review_for(&outcome);
if let (Some(reason), Outcome::Ran { .. }) = (narrowed, &outcome) {
let _ = write!(
ruling.body,
"\nThe classifier read the forge's diff, with 3 lines of context, not \
{CONTEXT_LINES}: {reason}.\n"
);
}
let posted_as = match ruling.event {
Event::Approve => RunOutcome::Approved,
Event::RequestChanges => RunOutcome::ChangesRequested,
@ -620,13 +628,14 @@ fn record(
}
}
/// The PR's head, when it was read, and how far the run got.
/// The PR's head when it was read, why its diff is the forge's 3-line one
/// when it is, and how far the run got.
async fn classify(
files: &impl FileSource,
pulls: &impl PullSource,
sink: &(impl RunSink + SettingsSource),
req: &Requested,
) -> (Option<String>, Outcome) {
) -> (Option<String>, Option<String>, Outcome) {
let repo = format!("{}/{}", req.owner, req.name);
let pull = match forge::within_forge_deadline(
"review classifier PR read",
@ -637,24 +646,28 @@ async fn classify(
Some(Ok(pull)) => pull,
Some(Err(e)) => {
return (
None,
None,
Outcome::NotRun(format!("reading the PR failed: {e:#}")),
);
}
None => {
return (
None,
None,
Outcome::NotRun("the forge did not answer within the deadline".to_owned()),
);
}
};
let head = Some(pull.head_sha.clone());
let narrowed = pull.narrowed;
let rules = match super::rules(files, &req.owner, &req.name).await {
Ok(axum::Json(rules)) => rules,
Err(problem) => {
let detail = problem.detail.unwrap_or_default();
return (
head,
narrowed,
Outcome::NotRun(format!("the rules did not load: {detail}")),
);
}
@ -664,6 +677,7 @@ async fn classify(
Err(e) => {
return (
head,
narrowed,
Outcome::NotRun(format!("reading the classifier's settings failed: {e:#}")),
);
}
@ -673,6 +687,7 @@ async fn classify(
Err(e) => {
return (
head,
narrowed,
Outcome::NotRun(format!("the classifier's settings: {e}")),
);
}
@ -697,7 +712,7 @@ async fn classify(
},
Err(e) => Outcome::NotRun(format!("encoding the run failed: {e}")),
};
(head, outcome)
(head, narrowed, outcome)
}
/// What a run posts: the event, the review body, and why it has no clean
@ -1993,4 +2008,43 @@ mod tests {
assert_eq!(Requested::parse(body.as_bytes()), None, "{body}");
}
}
#[tokio::test]
async fn a_run_on_the_forge_diff_says_so_in_its_review() {
/// The body a run posts when the PR read was narrowed for `reason`,
/// with the rules on the default branch or without them.
async fn narrowed(reason: Option<String>, rules: bool) -> String {
let reply = Reply::Done {
rows: vec![row(None, true, true)],
overall: Overall::Approve,
};
let files = if rules {
vec![rules_json()]
} else {
Vec::new()
};
let mut fake = Fake::new(reply, files);
fake.narrowed = reason;
let poster = Posted::default();
run(&fake, &fake, &fake, &poster, &request("r")).await;
poster.0.into_inner().expect("unpoisoned").remove(0).2.body
}
let reason = || Some("the PR changes 101 files".to_owned());
let body = narrowed(reason(), true).await;
assert!(
body.ends_with(
"\nThe classifier read the forge's diff, with 3 lines of context, not 200: \
the PR changes 101 files.\n"
),
"{body}"
);
let body = narrowed(None, true).await;
assert!(!body.contains("forge's diff"), "{body}");
let body = narrowed(reason(), false).await;
assert!(body.starts_with("**No verdict:**"), "{body}");
assert!(
!body.contains("forge's diff"),
"a run that never ran read no diff: {body}"
);
}
}