Watch
0
0
Fork
You've already forked hyperhive
0

review classifier: a setting makes the PR review list only the failed rules

failures_only (off by default, also for a stored document without it) makes the
review the classifier posts on a PR list just the rows that failed their rule,
or say that no check failed on an approval. A review with no clean verdict and
a try-out keep every row. The classifier settings form gets a checkbox for it.
This commit is contained in:
iris 2026-11-11 00:55:46 +01:00 • committed by mara
commit cf5746188a
8 changed files with 138 additions and 9 deletions

View file

@ -297,7 +297,10 @@ a repo admin adds it as a collaborator with write access
The **classifier settings** section shows the runtime, model and The **classifier settings** section shows the runtime, model and
effort the [review classifier](../../4-implementation/swarm/review-classifier.md) runs with and how many effort the [review classifier](../../4-implementation/swarm/review-classifier.md) runs with and how many
requested reviews run at once, and lets you change them. The runtime is **API requested reviews run at once, and lets you change them. The **PR reviews list
only failed rules** checkbox makes the review the classifier posts on a PR leave
out the rules that passed or didn't apply; try-outs always show every rule. The
runtime is **API
key** (claude on the Anthropic key) or **ACP** (opencode on a provider). On the key** (claude on the Anthropic key) or **ACP** (opencode on a provider). On the
API key runtime the model is free text, one word as claude's `--model` takes API key runtime the model is free text, one word as claude's `--model` takes
it, such as `haiku` or `sonnet`. Choosing ACP shows the provider's fields in it, such as `haiku` or `sonnet`. Choosing ACP shows the provider's fields in

View file

@ -216,6 +216,7 @@ replaces it.
"effort": null, "effort": null,
"max_concurrent_reviews": 2, "max_concurrent_reviews": 2,
"max_classifications": 10, "max_classifications": 10,
"failures_only": false,
"runtime": "api-key", "runtime": "api-key",
"acp": null "acp": null
} }
@ -238,6 +239,11 @@ replaces it.
three minutes, so the controller waits a minute plus three per three minutes, so the controller waits a minute plus three per
classification for the reply (31 minutes for ten), and the classifier may classification for the reply (31 minutes for ten), and the classifier may
answer for as long as the biggest run the limit allows answer for as long as the biggest run the limit allows
- `failures_only`: a boolean, `false` by default (also for a document stored
before the field existed). When `true`, a posted PR review lists only the
rows that failed their rule, the ones that make it ask for changes, and an
approval says `No check failed.` in place of the table. A review with no
verdict, and a try-out, always show every row
- `runtime`: `api-key` or `acp` (see [Runtimes](#runtimes)) - `runtime`: `api-key` or `acp` (see [Runtimes](#runtimes))
- `acp`: the provider the ACP runtime runs on, or `null`. It stays stored - `acp`: the provider the ACP runtime runs on, or `null`. It stays stored
while the runtime is `api-key`, so switching back and forth needs no while the runtime is `api-key`, so switching back and forth needs no

View file

@ -15,6 +15,7 @@ import { Button } from "../../ui/button/Button.js";
import { FieldGrid, FieldGridFull } from "../../ui/field-grid/FieldGrid.js"; import { FieldGrid, FieldGridFull } from "../../ui/field-grid/FieldGrid.js";
import { Hint } from "../../ui/hint/Hint.js"; import { Hint } from "../../ui/hint/Hint.js";
import { Disclosure } from "../../ui/disclosure/Disclosure.js"; import { Disclosure } from "../../ui/disclosure/Disclosure.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { SelectField } from "../../ui/select-field/SelectField.js"; import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextField } from "../../ui/text-field/TextField.js"; import { TextField } from "../../ui/text-field/TextField.js";
import { import {
@ -187,6 +188,12 @@ export function ClassifierSettings() {
pattern="[0-9]+" pattern="[0-9]+"
onInput={(maxRules) => edit({ ...draft, maxRules })} onInput={(maxRules) => edit({ ...draft, maxRules })}
/> />
<CheckboxField
id="classifier-failures-only"
label="PR reviews list only failed rules"
checked={draft.failuresOnly}
onChange={(failuresOnly) => edit({ ...draft, failuresOnly })}
/>
</FieldGrid> </FieldGrid>
{draft.runtime === "acp" ? ( {draft.runtime === "acp" ? (
<fieldset class="ui-classifier-settings-group"> <fieldset class="ui-classifier-settings-group">

View file

@ -35,6 +35,8 @@ export interface ClassifierSettingsBody {
max_concurrent_reviews: number; max_concurrent_reviews: number;
/** The most classifications (rules) one run carries. */ /** The most classifications (rules) one run carries. */
max_classifications: number; max_classifications: number;
/** Whether a posted PR review lists only the rules that failed. */
failures_only: boolean;
runtime: Runtime; runtime: Runtime;
/** The provider record. It stays stored while the runtime is `api-key`. */ /** The provider record. It stays stored while the runtime is `api-key`. */
acp: AcpSettings | null; acp: AcpSettings | null;
@ -48,6 +50,8 @@ export interface SettingsDraft {
max: string; max: string;
/** The most classifications one run carries. */ /** The most classifications one run carries. */
maxRules: string; maxRules: string;
/** Whether a posted PR review lists only the failed rules. */
failuresOnly: boolean;
runtime: Runtime; runtime: Runtime;
acpUrl: string; acpUrl: string;
acpModel: string; acpModel: string;
@ -70,6 +74,7 @@ export function draftOf(s: ClassifierSettingsBody): SettingsDraft {
effort: s.effort ?? "", effort: s.effort ?? "",
max: String(s.max_concurrent_reviews), max: String(s.max_concurrent_reviews),
maxRules: String(s.max_classifications), maxRules: String(s.max_classifications),
failuresOnly: s.failures_only,
runtime: s.runtime, runtime: s.runtime,
acpUrl: acp?.base_url ?? "", acpUrl: acp?.base_url ?? "",
acpModel: acp?.model ?? "", acpModel: acp?.model ?? "",
@ -173,6 +178,7 @@ export function toSettings(
effort: d.effort === "" ? null : (d.effort as Effort), effort: d.effort === "" ? null : (d.effort as Effort),
max_concurrent_reviews: maxValue(d.max) ?? MIN_REVIEWS, max_concurrent_reviews: maxValue(d.max) ?? MIN_REVIEWS,
max_classifications: rulesValue(d.maxRules) ?? MIN_RULES, max_classifications: rulesValue(d.maxRules) ?? MIN_RULES,
failures_only: d.failuresOnly,
runtime: d.runtime, runtime: d.runtime,
}; };
return d.runtime === "acp" ? { ...body, acp: toAcp(d) } : body; return d.runtime === "acp" ? { ...body, acp: toAcp(d) } : body;
@ -190,6 +196,7 @@ export function changed(
d.effort !== now.effort || d.effort !== now.effort ||
d.max.trim() !== now.max || d.max.trim() !== now.max ||
d.maxRules.trim() !== now.maxRules || d.maxRules.trim() !== now.maxRules ||
d.failuresOnly !== now.failuresOnly ||
d.runtime !== now.runtime; d.runtime !== now.runtime;
if (base) return true; if (base) return true;
// The provider fields only save on the ACP runtime. // The provider fields only save on the ACP runtime.

View file

@ -20,6 +20,7 @@ const apiKey = {
effort: null, effort: null,
max_concurrent_reviews: 2, max_concurrent_reviews: 2,
max_classifications: 10, max_classifications: 10,
failures_only: false,
runtime: "api-key", runtime: "api-key",
acp: null, acp: null,
}; };
@ -60,6 +61,7 @@ test("the API key request leaves the provider out, so the stored one stays", ()
effort: null, effort: null,
max_concurrent_reviews: 2, max_concurrent_reviews: 2,
max_classifications: 10, max_classifications: 10,
failures_only: false,
runtime: "api-key", runtime: "api-key",
}); });
assert.equal("acp" in body, false); assert.equal("acp" in body, false);
@ -166,3 +168,11 @@ test("under ACP the summary names the provider's model, not the claude one", ()
"acp · glm · 2 at once · up to 10 rules", "acp · glm · 2 at once · up to 10 rules",
); );
}); });
test("the failures-only switch round-trips and counts as a change", () => {
const on = { ...draftOf(apiKey), failuresOnly: true };
assert.equal(draftOf(apiKey).failuresOnly, false);
assert.equal(toSettings(on).failures_only, true);
assert.equal(changed(on, apiKey), true);
assert.equal(changed(draftOf(apiKey), apiKey), false);
});

View file

@ -1053,6 +1053,7 @@ pub(crate) mod tests {
effort: Some(Effort::High), effort: Some(Effort::High),
max_concurrent_reviews: 2, max_concurrent_reviews: 2,
max_classifications: 10, max_classifications: 10,
failures_only: false,
runtime: settings::RuntimeKind::Acp, runtime: settings::RuntimeKind::Acp,
acp: Some(acp.clone()), acp: Some(acp.clone()),
}, },

View file

@ -855,7 +855,12 @@ enum Outcome {
/// Nothing reached the classifier, for this reason. /// Nothing reached the classifier, for this reason.
NotRun(String), NotRun(String),
/// The classifier answered a run of `sent` classifications. /// The classifier answered a run of `sent` classifications.
Ran { sent: usize, reply: Reply }, Ran {
sent: usize,
reply: Reply,
/// Whether the review lists only the rules that failed.
failures_only: bool,
},
} }
/// Read the rules, run them on `pull`, the PR as it was read, and post the /// Read the rules, run them on `pull`, the PR as it was read, and post the
@ -1106,6 +1111,7 @@ async fn classify(
); );
} }
}; };
let failures_only = settings.failures_only;
let request = TryOut { let request = TryOut {
operator: req.requester.clone(), operator: req.requester.clone(),
repo, repo,
@ -1123,6 +1129,7 @@ async fn classify(
Ok(payload) => Outcome::Ran { Ok(payload) => Outcome::Ran {
sent, sent,
reply: sink.run(payload, run_deadline(sent)).await, reply: sink.run(payload, run_deadline(sent)).await,
failures_only,
}, },
Err(e) => Outcome::NotRun(format!("encoding the run failed: {e}")), Err(e) => Outcome::NotRun(format!("encoding the run failed: {e}")),
}; };
@ -1170,6 +1177,7 @@ fn review_for(outcome: &Outcome) -> Ruling {
Outcome::Ran { Outcome::Ran {
sent, sent,
reply: Reply::Done { rows, overall }, reply: Reply::Done { rows, overall },
failures_only,
} => { } => {
let unanswered = rows.iter().filter(|r| r.error.is_some()).count(); let unanswered = rows.iter().filter(|r| r.error.is_some()).count();
if rows.is_empty() || rows.len() != *sent { if rows.is_empty() || rows.len() != *sent {
@ -1191,7 +1199,15 @@ fn review_for(outcome: &Outcome) -> Ruling {
}; };
Ruling { Ruling {
event, event,
body: format!("{}\n{}", table(rows), overall_line(*overall)), body: format!(
"{}\n{}",
if *failures_only {
failures(rows)
} else {
table(rows)
},
overall_line(*overall)
),
reason: None, reason: None,
} }
} }
@ -1235,6 +1251,21 @@ fn table(rows: &[Row]) -> String {
out out
} }
/// The result table of only the rows that failed their rule, the ones that
/// make the review ask for changes, or a line saying none did.
fn failures(rows: &[Row]) -> String {
let failed: Vec<Row> = rows
.iter()
.filter(|r| r.counted && !r.passed)
.cloned()
.collect();
if failed.is_empty() {
"No check failed.\n".to_owned()
} else {
table(&failed)
}
}
/// A row's result cell: the symbol for what the row did to the review, then /// A row's result cell: the symbol for what the row did to the review, then
/// the model's verdict. ✅ is a counted row that passed its polarity, ❌ a /// the model's verdict. ✅ is a counted row that passed its polarity, ❌ a
/// counted row that failed it, ❔ a row that doesn't count: below its /// counted row that failed it, ❔ a row that doesn't count: below its
@ -1456,6 +1487,54 @@ mod tests {
); );
} }
fn done(failures_only: bool, rows: Vec<Row>, overall: Overall) -> Ruling {
review_for(&Outcome::Ran {
sent: rows.len(),
failures_only,
reply: Reply::Done { rows, overall },
})
}
#[test]
fn failures_only_lists_just_the_rows_that_failed() {
let mut failed = row(None, true, false);
failed.name = "no-secrets".to_owned();
let rows = vec![row(None, true, true), failed, row(None, false, true)];
let all = done(false, rows.clone(), Overall::RequestChanges);
assert!(all.body.contains("datetime"), "{}", all.body);
assert!(all.body.contains("no-secrets"), "{}", all.body);
let only = done(true, rows, Overall::RequestChanges);
assert_eq!(only.event, Event::RequestChanges);
assert!(only.body.contains("no-secrets"), "{}", only.body);
assert!(!only.body.contains("datetime"), "{}", only.body);
assert!(only.body.ends_with("**Overall: ❌ request changes**\n"));
}
#[test]
fn failures_only_on_an_approval_says_no_check_failed() {
let ruling = done(
true,
vec![row(None, true, true), row(None, false, true)],
Overall::Approve,
);
assert_eq!(ruling.event, Event::Approve);
assert_eq!(ruling.body, "No check failed.\n\n**Overall: ✅ approve**\n");
}
#[test]
fn failures_only_keeps_the_full_table_when_a_row_got_no_answer() {
let ruling = done(
true,
vec![row(None, true, true), row(Some("timed out"), false, false)],
Overall::Approve,
);
assert_eq!(ruling.event, Event::Comment);
assert!(ruling.body.contains("datetime"), "{}", ruling.body);
assert!(ruling.body.contains("timed out"), "{}", ruling.body);
}
fn request(name: &str) -> Requested { fn request(name: &str) -> Requested {
Requested { Requested {
owner: "o".to_owned(), owner: "o".to_owned(),
@ -1568,6 +1647,7 @@ mod tests {
// empty answer to one still has no verdict. // empty answer to one still has no verdict.
let ruling = review_for(&Outcome::Ran { let ruling = review_for(&Outcome::Ran {
sent: 0, sent: 0,
failures_only: false,
reply: Reply::Done { reply: Reply::Done {
rows: Vec::new(), rows: Vec::new(),
overall: Overall::Approve, overall: Overall::Approve,
@ -2727,6 +2807,7 @@ mod tests {
effort: Some(Effort::Medium), effort: Some(Effort::Medium),
max_concurrent_reviews: 2, max_concurrent_reviews: 2,
max_classifications: 10, max_classifications: 10,
failures_only: false,
runtime: super::super::settings::RuntimeKind::Acp, runtime: super::super::settings::RuntimeKind::Acp,
acp: Some(acp.clone()), acp: Some(acp.clone()),
}; };

View file

@ -64,6 +64,12 @@ pub(crate) struct Settings {
/// minutes. A document stored before this field has the default. /// minutes. A document stored before this field has the default.
#[serde(default = "default_max_classifications")] #[serde(default = "default_max_classifications")]
pub(crate) max_classifications: usize, pub(crate) max_classifications: usize,
/// Whether a posted PR review lists only the rules that failed, rather than
/// every rule with its result. A review with no failed rule says so in a
/// line. A try-out shows every rule either way. A document stored before this
/// field has `false`.
#[serde(default)]
pub(crate) failures_only: bool,
/// What every classification runs on. /// What every classification runs on.
#[serde(default)] #[serde(default)]
pub(crate) runtime: RuntimeKind, pub(crate) runtime: RuntimeKind,
@ -91,6 +97,7 @@ impl Default for Settings {
effort: None, effort: None,
max_concurrent_reviews: 2, max_concurrent_reviews: 2,
max_classifications: DEFAULT_MAX_CLASSIFICATIONS, max_classifications: DEFAULT_MAX_CLASSIFICATIONS,
failures_only: false,
runtime: RuntimeKind::ApiKey, runtime: RuntimeKind::ApiKey,
acp: None, acp: None,
} }
@ -191,6 +198,9 @@ pub(crate) struct SettingsUpdate {
#[schema(value_type = Option<usize>)] #[schema(value_type = Option<usize>)]
max_classifications: Option<usize>, max_classifications: Option<usize>,
#[serde(default, deserialize_with = "present")] #[serde(default, deserialize_with = "present")]
#[schema(value_type = Option<bool>)]
failures_only: Option<bool>,
#[serde(default, deserialize_with = "present")]
#[schema(value_type = Option<RuntimeKind>)] #[schema(value_type = Option<RuntimeKind>)]
runtime: Option<RuntimeKind>, runtime: Option<RuntimeKind>,
#[serde(default)] #[serde(default)]
@ -244,6 +254,7 @@ impl SettingsUpdate {
max_classifications: self max_classifications: self
.max_classifications .max_classifications
.unwrap_or(stored.max_classifications), .unwrap_or(stored.max_classifications),
failures_only: self.failures_only.unwrap_or(stored.failures_only),
runtime: self.runtime.unwrap_or(stored.runtime), runtime: self.runtime.unwrap_or(stored.runtime),
acp: self.acp.or(stored.acp), acp: self.acp.or(stored.acp),
} }
@ -277,6 +288,7 @@ const FIELDS: &[&str] = &[
"effort", "effort",
"max_concurrent_reviews", "max_concurrent_reviews",
"max_classifications", "max_classifications",
"failures_only",
"runtime", "runtime",
"acp", "acp",
]; ];
@ -329,7 +341,7 @@ fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
responses( responses(
(status = 200, description = "the settings in force; with none stored, \ (status = 200, description = "the settings in force; with none stored, \
`{\"model\":\"haiku\",\"effort\":null,\"max_concurrent_reviews\":2,\ `{\"model\":\"haiku\",\"effort\":null,\"max_concurrent_reviews\":2,\
\"max_classifications\":10,\"runtime\":\"api-key\",\"acp\":null}`", \"max_classifications\":10,\"failures_only\":false,\"runtime\":\"api-key\",\"acp\":null}`",
body = Settings), body = Settings),
(status = 503, description = "no swarm queue is configured on this host, or it is \ (status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String), not connected (problem+json)", body = String),
@ -360,7 +372,7 @@ pub(crate) async fn get_settings(
(status = 400, description = "not JSON or not this shape: an unknown field, a \ (status = 400, description = "not JSON or not this shape: an unknown field, a \
`null` other than for `effort` or `acp`, an effort other than the five \ `null` other than for `effort` or `acp`, an effort other than the five \
levels, a negative or non-integer `max_concurrent_reviews` or \ levels, a negative or non-integer `max_concurrent_reviews` or \
`max_classifications`, a runtime other \ `max_classifications`, a `failures_only` that is not a boolean, a runtime other \
than `api-key` or `acp`, an `acp` without `base_url`, `model` or `prices`; or, \ than `api-key` or `acp`, an `acp` without `base_url`, `model` or `prices`; or, \
once applied, a model that is empty, has whitespace or starts with `-`, \ once applied, a model that is empty, has whitespace or starts with `-`, \
`max_concurrent_reviews` outside 1 to 8, `max_classifications` outside 1 to \ `max_concurrent_reviews` outside 1 to 8, `max_classifications` outside 1 to \
@ -449,7 +461,7 @@ mod tests {
serde_json::to_value(Settings::default()).unwrap(), serde_json::to_value(Settings::default()).unwrap(),
serde_json::json!({ serde_json::json!({
"model": "haiku", "effort": null, "max_concurrent_reviews": 2, "model": "haiku", "effort": null, "max_concurrent_reviews": 2,
"max_classifications": 10, "runtime": "api-key", "acp": null, "max_classifications": 10, "failures_only": false, "runtime": "api-key", "acp": null,
}) })
); );
assert_eq!(Settings::default().runtime(), Ok(Runtime::ApiKey)); assert_eq!(Settings::default().runtime(), Ok(Runtime::ApiKey));
@ -464,6 +476,7 @@ mod tests {
effort: Some(Effort::High), effort: Some(Effort::High),
max_concurrent_reviews: 5, max_concurrent_reviews: 5,
max_classifications: 40, max_classifications: 40,
failures_only: true,
runtime: RuntimeKind::Acp, runtime: RuntimeKind::Acp,
acp: Some(provider()), acp: Some(provider()),
}; };
@ -637,6 +650,7 @@ mod tests {
effort: Some(Effort::High), effort: Some(Effort::High),
max_concurrent_reviews: 3, max_concurrent_reviews: 3,
max_classifications: 25, max_classifications: 25,
failures_only: true,
runtime: RuntimeKind::Acp, runtime: RuntimeKind::Acp,
acp: Some(provider()), acp: Some(provider()),
}; };
@ -732,7 +746,7 @@ mod tests {
read, read,
serde_json::json!({ serde_json::json!({
"model": "sonnet", "effort": "high", "max_concurrent_reviews": 3, "model": "sonnet", "effort": "high", "max_concurrent_reviews": 3,
"max_classifications": 10, "runtime": "api-key", "acp": null, "max_classifications": 10, "failures_only": false, "runtime": "api-key", "acp": null,
}) })
); );
@ -770,7 +784,7 @@ mod tests {
let wanted = serde_json::json!({ let wanted = serde_json::json!({
"model": "sonnet", "effort": "high", "max_concurrent_reviews": 5, "model": "sonnet", "effort": "high", "max_concurrent_reviews": 5,
"max_classifications": 10, "runtime": "api-key", "acp": null, "max_classifications": 10, "failures_only": false, "runtime": "api-key", "acp": null,
}); });
let (status, stored, _) = send(&app, put(&wanted.to_string())).await; let (status, stored, _) = send(&app, put(&wanted.to_string())).await;
assert_eq!(status, StatusCode::OK, "{stored}"); assert_eq!(status, StatusCode::OK, "{stored}");
@ -811,7 +825,7 @@ mod tests {
after, after,
serde_json::json!({ serde_json::json!({
"model": "sonnet", "effort": null, "max_concurrent_reviews": 4, "model": "sonnet", "effort": null, "max_concurrent_reviews": 4,
"max_classifications": 10, "runtime": "acp", "acp": acp, "max_classifications": 10, "failures_only": false, "runtime": "acp", "acp": acp,
}) })
); );
let read = nats.client().await.settings().await.expect("readable"); let read = nats.client().await.settings().await.expect("readable");