//! A triager triaging a repository on the reference PDS: a classifier, a //! script here, chooses each open issue's labels, written as the //! triager's own records, which count for nothing until the owner names //! it among the triagers, and which the owner can hold against people's. #[path = "../../knotless/tests/common/mod.rs"] mod common; use common::{Sandbox, net, wait_until}; use futures::{SinkExt, StreamExt}; use knotless::keys::label_rkey; use knotless_triage::decide::Decider; use knotless_triage::{Classifier, Options}; use serde_json::{Value, json}; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; const KIND: &str = "network.bsky.jetstream.subscribeEvents#"; /// A Jetstream that sends what the test hands it, to whoever is connected. struct Jetstream { url: String, frames: tokio::sync::mpsc::UnboundedSender, } // The handshake callback's error type is tungstenite's, not ours. #[allow(clippy::result_large_err)] fn jetstream(rt: &tokio::runtime::Runtime) -> Jetstream { let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); let url = format!("ws://{}", listener.local_addr().unwrap()); let (frames, rx) = tokio::sync::mpsc::unbounded_channel::(); let rx = Arc::new(Mutex::new(rx)); rt.spawn(async move { loop { let (tcp, _) = listener.accept().await.unwrap(); let rx = rx.clone(); tokio::spawn(async move { let offered = |req: &Request, mut res: Response| { let protocol = req.headers().get("sec-websocket-protocol").cloned(); res.headers_mut() .insert("sec-websocket-protocol", protocol.unwrap()); Ok(res) }; let Ok(mut ws) = tokio_tungstenite::accept_hdr_async(tcp, offered).await else { return; }; // Reading answers the client's pings, as Jetstream does: one // left unanswered gives the connection up, and takes the // next frame with it. let mut rx = rx.lock().await; loop { tokio::select! { frame = rx.recv() => { let Some(frame) = frame else { return }; if ws.send(frame).await.is_err() { return; } } read = ws.next() => { if !matches!(read, Some(Ok(_))) { return; } } } } }); } }); Jetstream { url, frames } } impl Jetstream { fn send(&self, did: &str, collection: &str, rkey: &str, record: serde_json::Value) { let payload = serde_json::json!({ "$type": format!("{KIND}commit"), "seq": 1, "did": did, "time": "2026-10-24T00:00:00.000001Z", "rev": "3l3qo2vutsw2b", "operation": "create", "collection": collection, "rkey": rkey, "record": record, }); let frame = serde_json::json!({"$type": "message", "payload": payload}); let frames = [ Message::Ping(Vec::new().into()), Message::Text(frame.to_string().into()), ]; for frame in frames { self.frames.send(frame).unwrap(); } } } /// An appview over the reference PDS, following its firehose. fn appview(rt: &tokio::runtime::Runtime, s: &Sandbox) -> String { serving(rt, s, false) } /// An appview as [`appview`] makes, and a gateway too when `gateway`, for /// the service tokens of invites to private repositories. fn serving(rt: &tokio::runtime::Runtime, s: &Sandbox, gateway: bool) -> String { let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); // A gateway's did:web names a host, which an IP address is not. let base = format!("http://localhost:{}", listener.local_addr().unwrap().port()); let index = knotless_serve::index::Index::open(&s.path("index.db")).unwrap(); let gateway = gateway.then(|| knotless_serve::gateway::Gateway::new(&base).unwrap()); let app = knotless_serve::App::build(common::ctx(s), Duration::ZERO, gateway, Some(index)); let router = knotless_serve::router(app.clone()); rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); rt.spawn(knotless_serve::firehose::watch( app, net().pds.replace("http://", "ws://"), )); base } #[track_caller] fn ok(s: &Sandbox, args: &[&str]) -> String { let (code, out) = s.cli(args, b""); assert_eq!(code, 0, "{args:?}: {out}"); out } /// Answers by what the issue says: a crash is a bug in the CLI, dark mode /// a feature of the web pages; one broken fails, one garbled answers /// nothing readable, and one slow takes too long. It notes each issue it /// is asked about. const CLASSIFY: &str = r#"input=$(cat) echo "$input" >> "$ASKED" case "$input" in *Dark*) echo '{"labels": ["kind/feature", "area/web"]}' ;; *rash*) echo '{"labels": ["kind/bug", "kind/feature", "area/cli", "priority/high", "nope"], "model": "test-1", "reason": "It crashes."}' ;; *broken*) exit 3 ;; *garbled*) echo 'not json' ;; *slow*) sleep 5 ;; *) echo '{}' ;; esac "#; /// How many times the classifier was asked about an issue titled `title`. fn asked(bot: &Sandbox, title: &str) -> usize { let asked = std::fs::read_to_string(bot.path("asked")).unwrap_or_default(); let title = format!("\"title\":\"{title}\""); asked.lines().filter(|l| l.contains(&title)).count() } #[test] fn a_bot_triages_issues() { let rt = tokio::runtime::Runtime::new().unwrap(); let base = appview(&rt, &Sandbox::new()); let (mut owner, mut author, mut bot) = (Sandbox::new(), Sandbox::new(), Sandbox::new()); for s in [&mut owner, &mut author, &mut bot] { let handle = s.account.handle.clone(); s.set("KNOTLESS_APPVIEW", &base); s.set("KNOTLESS_IDENTIFIER", &handle); } let handle = bot.account.handle.clone(); std::fs::write(bot.path("classify.sh"), CLASSIFY).unwrap(); let asked_at = bot.path("asked").to_string_lossy().to_string(); let classify = format!("ASKED={asked_at} sh {}", bot.path("classify.sh").display()); let url = owner.url("proj"); ok(&owner, &["repo", "create", &url]); for label in [ &["kind/bug", "--exclusive"][..], &["kind/feature", "--exclusive"], &["area/web"], &["area/cli"], &["priority/high"], &["docs"], ] { ok(&owner, &[&["label", "define", &url][..], label].concat()); } let key = owner.record("dev.knotless.repo", "proj")["key"] .as_str() .unwrap() .to_string(); let issue = |title: &str, body: &str| ok(&author, &["issue", "create", &url, title, "-m", body]); let crash = issue("It crashes", "knotless show crashes on a pull"); let dark = issue("Dark mode", "for the web pages"); let taken = issue("Crash in the CLI", "when it starts"); let failing = [ issue("It is broken", "no answer"), issue("It is garbled", "no answer"), issue("It is slow", "no answer"), ]; // The owner said what kind of issue one is before the triager came, // and both kind and area of another, and closed a third. ok(&owner, &["issue", "label", &url, &taken, "kind/bug"]); let known = issue("Known crash", "in the CLI"); ok( &owner, &["issue", "label", &url, &known, "kind/bug", "area/cli"], ); let closed = issue("Old crash", "fixed"); ok(&owner, &["issue", "close", &url, &closed]); wait_until(|| { let closing = owner.cli(&["issue", "show", &url, &closed], b"").1; let shown = owner.cli(&["issue", "show", &url, &known], b"").1; shown.contains("area/cli") && closing.contains("closed") }); wait_until(|| { let shown = owner.cli(&["issue", "show", &url, &taken], b"").1; let listed = owner.cli(&["issue", "list", &url], b"").1; shown.contains("kind/bug") && failing .iter() .all(|f| listed.contains(f.rsplit('/').next().unwrap())) }); let opts = Options { classify: Classifier::Command(classify.clone()), scopes: vec!["kind".into(), "area".into()], jetstream: "ws://127.0.0.1:9".into(), sweep: Duration::from_secs(600), settle: Duration::ZERO, timeout: Duration::from_secs(1), once: true, discover: false, repos: vec![format!("{}/proj", owner.account.handle)], }; let once = |opts: &Options| { rt.block_on(knotless_triage::run(opts.clone(), common::ctx(&bot))) .unwrap() }; once(&opts); let labels = |subject: &str| bot.record("dev.knotless.label", &label_rkey(&key, subject)); let set = |subject: &str| -> Vec { let record = labels(subject); let entries = record["labels"].as_array().cloned().unwrap_or_default(); entries .iter() .filter(|e| e["removed"] != true) .map(|e| e["name"].as_str().unwrap().to_string()) .collect() }; // Of a scope's exclusive labels it sets only the first chosen, and // none it was not offered: no priority, which it was not asked to // choose, and nothing the repository does not define. assert_eq!(set(&crash), ["kind/bug", "area/cli"]); let judged = labels(&crash)["judged"].clone(); assert_eq!(judged["model"], "test-1"); assert_eq!(judged["reason"], "It crashes."); let cid = judged["cid"].as_str().unwrap().to_string(); assert_eq!(set(&dark), ["kind/feature", "area/web"]); assert_eq!(labels(&dark)["judged"]["model"], serde_json::Value::Null); // Where the owner put a kind on, it was asked only for an area. assert_eq!(set(&taken), ["area/cli"]); let input = std::fs::read_to_string(bot.path("asked")).unwrap(); let about_taken = input .lines() .find(|l| l.contains("Crash in the CLI")) .unwrap(); assert!(!about_taken.contains("kind/"), "{about_taken}"); assert!(about_taken.contains("\"area/web\""), "{about_taken}"); // Nothing was left to judge of the one labelled, nor of the closed one. for title in ["Known crash", "Old crash"] { assert_eq!(asked(&bot, title), 0, "{title}"); } // What failed it wrote nothing of. for f in &failing { assert_eq!(labels(f), serde_json::Value::Null); } // Its labels count for nothing: it is no triager here. assert!(!ok(&owner, &["issue", "show", &url, &dark]).contains("kind/feature")); // Read again, by the binary, it judges only what failed before. let ran = std::process::Command::new(env!("CARGO_BIN_EXE_knotless-triage")) .args(["--classify", &classify, "--scopes", "kind,area"]) .args(["--timeout", "1", "--once", &opts.repos[0]]) .envs(bot.vars()) .output() .unwrap(); assert!(ran.status.success(), "{ran:?}"); assert_eq!(asked(&bot, "It crashes"), 1); assert_eq!(asked(&bot, "It is broken"), 2); // An issue changed is judged again, and what it no longer chooses is // taken off. ok( &author, &[ "issue", "edit", &url, &crash, "--title", "Dark mode crashes", ], ); once(&opts); assert_eq!(set(&crash), ["kind/feature", "area/web"]); assert_ne!(labels(&crash)["judged"]["cid"], cid.as_str()); // How it compares with the owner's: on the crash, which kind, they // agree; on dark mode, they do not; nobody else said an area. ok(&owner, &["issue", "label", &url, &crash, "kind/feature"]); ok(&owner, &["issue", "label", &url, &dark, "kind/bug"]); wait_until(|| { let eval = owner.cli(&["triager", "eval", &url, &handle], b"").1; eval == "kind\t2\t2\t1\narea\t3\t0\t0\npriority\t0\t0\t0" }); // Watching, with no Jetstream to hear from, it reads every --sweep. let mut watch = Options { once: false, sweep: Duration::from_millis(200), ..opts.clone() }; let sweeping = rt.spawn(knotless_triage::run(watch.clone(), common::ctx(&bot))); let swept = issue("Dark corners", "of the web pages"); wait_until(|| set(&swept) == ["kind/feature", "area/web"]); sweeping.abort(); // With Jetstream, it reads a repository when an issue about it is // written, or its record: here, one now gone, which it goes on past. let gone = owner.url("gone"); ok(&owner, &["repo", "create", &gone]); let js = jetstream(&rt); watch.jetstream = js.url.clone(); watch.sweep = Duration::from_secs(600); watch .repos .insert(0, format!("{}/gone", owner.account.handle)); let before = asked(&bot, "It is broken"); rt.spawn(knotless_triage::run(watch, common::ctx(&bot))); // Connecting, it reads them all. wait_until(|| asked(&bot, "It is broken") > before); ok(&owner, &["repo", "delete", &gone, "--yes"]); // Rung only once the appview has it, as it would be after --settle. let indexed = |subject: &str| { let rkey = subject.rsplit('/').next().unwrap().to_string(); wait_until(|| owner.cli(&["issue", "list", &url], b"").1.contains(&rkey)); }; let heard = issue("Dark skies", "for the web pages"); indexed(&heard); let rkey = heard.rsplit('/').next().unwrap(); let record = author.record("dev.knotless.issue", rkey); let mut elsewhere = record.clone(); elsewhere["repo"]["key"] = "3zzzzzzzzzzzz".into(); js.send(&author.account.did, "dev.knotless.issue", rkey, elsewhere); js.send( &author.account.did, "dev.knotless.check", rkey, record.clone(), ); js.send(&author.account.did, "dev.knotless.issue", rkey, record); wait_until(|| set(&heard) == ["kind/feature", "area/web"]); let defined = owner.record("dev.knotless.repo", "proj"); let more = issue("Dark lines", "for the web pages"); indexed(&more); js.send(&owner.account.did, "dev.knotless.repo", "proj", defined); wait_until(|| set(&more) == ["kind/feature", "area/web"]); } /// A triager with `--discover` triages every repository naming it among /// its triagers, in the scopes it names it a triager of: those the /// appview lists, and those Jetstream brings, until one no longer does. #[test] fn a_bot_finds_repositories_naming_it() { let rt = tokio::runtime::Runtime::new().unwrap(); let base = appview(&rt, &Sandbox::new()); let (mut owner, mut bot) = (Sandbox::new(), Sandbox::new()); for s in [&mut owner, &mut bot] { let handle = s.account.handle.clone(); s.set("KNOTLESS_APPVIEW", &base); s.set("KNOTLESS_IDENTIFIER", &handle); } let handle = bot.account.handle.clone(); std::fs::write(bot.path("classify.sh"), CLASSIFY).unwrap(); let asked_at = bot.path("asked").to_string_lossy().to_string(); let classify = format!("ASKED={asked_at} sh {}", bot.path("classify.sh").display()); // Each repository defines a kind and an area, and has an issue of both. let made = |name: &str, title: &str| { let url = owner.url(name); ok(&owner, &["repo", "create", &url]); ok( &owner, &["label", "define", &url, "kind/feature", "--exclusive"], ); ok(&owner, &["label", "define", &url, "area/web"]); let issue = ok( &owner, &["issue", "create", &url, title, "-m", "for the web pages"], ); let key = owner.record("dev.knotless.repo", name)["key"] .as_str() .unwrap() .to_string(); (url, key, issue) }; let set = |key: &str, subject: &str| -> Vec { let record = bot.record("dev.knotless.label", &label_rkey(key, subject)); let entries = record["labels"].as_array().cloned().unwrap_or_default(); (entries.iter()) .filter(|e| e["removed"] != true) .map(|e| e["name"].as_str().unwrap().to_string()) .collect() }; let indexed = |url: &str, subject: &str| { let rkey = subject.rsplit('/').next().unwrap().to_string(); wait_until(|| owner.cli(&["issue", "list", url], b"").1.contains(&rkey)); }; let (named, named_key, dark) = made("named", "Dark mode"); let (unnamed, _, other) = made("unnamed", "Dark mode too"); // Named a triager of kinds alone; and of the other, of a scope it is // not asked to label. ok(&owner, &["triager", "add", &named, &handle, "kind"]); ok(&owner, &["triager", "add", &unnamed, &handle, "size"]); indexed(&named, &dark); indexed(&unnamed, &other); let listed = format!( "{base}/xrpc/dev.knotless.listRepos?triager={}", bot.account.did ); wait_until(|| { let page = reqwest::blocking::get(&listed).unwrap().text().unwrap(); page.contains("\"named\"") }); let opts = Options { classify: Classifier::Command(classify), scopes: vec!["kind".into(), "area".into()], jetstream: "ws://127.0.0.1:9".into(), sweep: Duration::from_secs(600), settle: Duration::ZERO, timeout: Duration::from_secs(5), once: true, discover: true, repos: Vec::new(), }; rt.block_on(knotless_triage::run(opts.clone(), common::ctx(&bot))) .unwrap(); // A kind, as it is a triager of, and no area; nothing of the other, // where it labels no scope it was asked to. assert_eq!(set(&named_key, &dark), ["kind/feature"]); assert_eq!(asked(&bot, "Dark mode too"), 0); // Watching, it finds one named since as Jetstream brings its record, // beside one named on the command line, which it reads as it was. let js = jetstream(&rt); let watch = Options { once: false, jetstream: js.url.clone(), repos: vec![format!("{}/named", owner.account.handle)], ..opts }; rt.spawn(knotless_triage::run(watch, common::ctx(&bot))); let (later, later_key, lit) = made("later", "Dark lights"); ok(&owner, &["triager", "add", &later, &handle, "kind"]); indexed(&later, &lit); let record = owner.record("dev.knotless.repo", "later"); // One whose owner cannot be found is passed over. js.send( "did:plc:nobody", "dev.knotless.repo", "gone", record.clone(), ); // So is one not there, and one that names no triager. ok(&owner, &["repo", "create", &owner.url("plain")]); for name in ["missing", "plain"] { js.send( &owner.account.did, "dev.knotless.repo", name, record.clone(), ); } for _ in 0..2 { js.send( &owner.account.did, "dev.knotless.repo", "later", record.clone(), ); } wait_until(|| set(&later_key, &lit) == ["kind/feature"]); // No longer named, it is left at the next read. let (code, out) = owner.cli(&["triager", "remove", &later, &handle], b""); assert_eq!(code, 0, "{out}"); let unseen = ok( &owner, &[ "issue", "create", &later, "Dark again", "-m", "for the web pages", ], ); indexed(&later, &unseen); for title in ["Dark one", "Dark two"] { let rung = ok( &owner, &["issue", "create", &named, title, "-m", "for the web pages"], ); indexed(&named, &rung); let record = owner.record("dev.knotless.repo", "named"); js.send(&owner.account.did, "dev.knotless.repo", "named", record); wait_until(|| !set(&named_key, &rung).is_empty()); } assert_eq!(asked(&bot, "Dark again"), 0); } /// A private repository naming a triager makes it a member and invites /// it, which it finds as a runner does. These need a PDS on the spaces /// alpha, and pass trivially without one. #[test] fn a_bot_finds_a_private_repository_by_its_invite() { if !net().spaces { eprintln!("skipped: the PDS is not on the spaces alpha"); return; } let rt = tokio::runtime::Runtime::new().unwrap(); let base = serving(&rt, &Sandbox::new(), true); let mut owner = Sandbox::new(); // It reads as itself, logged in, as a space credential needs. let mut bot = Sandbox::new(); bot.set("KNOTLESS_PASSWORD", ""); let handle = bot.account.handle.clone(); let password = bot.account.password.clone(); let login = bot.cli(&["login", &handle, "--password-stdin"], password.as_bytes()); assert_eq!(login.0, 0, "{}", login.1); let owned = owner.account.handle.clone(); owner.set("KNOTLESS_APPVIEW", &base); owner.set("KNOTLESS_IDENTIFIER", &owned); bot.set("KNOTLESS_APPVIEW", &base); std::fs::write(bot.path("classify.sh"), CLASSIFY).unwrap(); let asked_at = bot.path("asked").to_string_lossy().to_string(); let classify = format!("ASKED={asked_at} sh {}", bot.path("classify.sh").display()); let url = owner.url("hidden"); ok( &owner, &["repo", "create", &url, "--private", "--no-runner"], ); ok( &owner, &["label", "define", &url, "kind/feature", "--exclusive"], ); let dark = ok( &owner, &[ "issue", "create", &url, "Dark mode", "-m", "for the web pages", ], ); let out = ok(&owner, &["triager", "add", &url, &handle, "kind"]); assert!(!out.contains("warning"), "{out}"); // With no appview to tell, it is named and a member still. let alone = Sandbox::with(owner.account.clone()); let lone = owner.url("lone"); ok( &alone, &["repo", "create", &lone, "--private", "--no-runner"], ); let out = ok(&alone, &["triager", "add", &lone, &handle, "kind"]); assert!( out.contains("warning: ") && out.contains("invites go through an appview"), "{out}" ); // Its account reads it. let (code, out) = bot.cli(&["issue", "list", &url], b""); assert_eq!(code, 0, "{out}"); let opts = Options { classify: Classifier::Command(classify), scopes: vec!["kind".into()], jetstream: "ws://127.0.0.1:9".into(), sweep: Duration::from_secs(600), settle: Duration::ZERO, timeout: Duration::from_secs(5), once: true, discover: true, repos: Vec::new(), }; rt.block_on(knotless_triage::run(opts, common::ctx(&bot))) .unwrap(); assert_eq!(asked(&bot, "Dark mode"), 1); wait_until(|| ok(&owner, &["issue", "show", &url, &dark]).contains("kind/feature")); } /// A decision model that answers by the issue's title, as Workers AI /// wraps Clef's answers or as Jev gives its own, or fails, and keeps what /// it was asked. fn decision_model(rt: &tokio::runtime::Runtime) -> (String, Arc>>) { use axum::http::{HeaderMap, StatusCode}; use axum::{Json, extract::State, routing::post}; type Asked = Arc>>; async fn answer( State(asked): State, headers: HeaderMap, Json(body): Json, ) -> (StatusCode, String) { if headers["authorization"] != "Bearer t0ken" { return (StatusCode::UNAUTHORIZED, "who?".into()); } asked.lock().unwrap().push(body.clone()); let title = body["state"]["title"] .as_str() .unwrap_or_default() .to_string(); let answers = match title.as_str() { "It crashes" => json!({"result": {"answers": { "kind": {"type": "choice", "choice": "bug", "confidence": 0.91}, "area_web": {"type": "noul", "noul": 0.1}, "area_cli": {"type": "noul", "noul": 0.8}, }}}), "Dark mode" => json!({"model": "jev-1.13.0", "answers": { "kind": {"type": "choice", "choice": "feature", "confidence": 0.3}, "area_web": {"type": "noul", "noul": 0.9}, "area_cli": {"type": "noul", "noul": 0.05}, }}), "Unclear" => json!({"answers": { "kind": {"type": "choice", "choice": "none_of_these", "confidence": 0.8}, }}), "It is garbled" => json!({"result": {}}), _ => return (StatusCode::SERVICE_UNAVAILABLE, "overloaded".into()), }; (StatusCode::OK, answers.to_string()) } let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); let url = format!("http://{}/v1/systemone", listener.local_addr().unwrap()); let asked: Asked = Arc::default(); let router = axum::Router::new() .route("/v1/systemone", post(answer)) .with_state(asked.clone()); rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); (url, asked) } #[test] fn a_decision_model_triages_issues() { let rt = tokio::runtime::Runtime::new().unwrap(); let base = appview(&rt, &Sandbox::new()); let (mut owner, mut author, mut bot) = (Sandbox::new(), Sandbox::new(), Sandbox::new()); for s in [&mut owner, &mut author, &mut bot] { let handle = s.account.handle.clone(); s.set("KNOTLESS_APPVIEW", &base); s.set("KNOTLESS_IDENTIFIER", &handle); } let url = owner.url("decided"); ok(&owner, &["repo", "create", &url]); for label in [ &[ "kind/bug", "--exclusive", "--description", "Something is broken", ][..], &["kind/feature", "--exclusive"], &["area/web"], &["area/cli"], &["priority/high"], ] { ok(&owner, &[&["label", "define", &url][..], label].concat()); } let key = owner.record("dev.knotless.repo", "decided")["key"] .as_str() .unwrap() .to_string(); let issue = |title: &str| { ok( &author, &["issue", "create", &url, title, "-m", "as it says"], ) }; let titles = [ "It crashes", "Dark mode", "Unclear", "It is broken", "It is garbled", ]; let issues: Vec = titles.iter().map(|t| issue(t)).collect(); wait_until(|| { let listed = owner.cli(&["issue", "list", &url], b"").1; (issues.iter()).all(|i| listed.contains(i.rsplit('/').next().unwrap())) }); let (endpoint, asked) = decision_model(&rt); let decider = Decider { endpoint, token: "t0ken".into(), model: "clef".into(), threshold: 0.5, }; let opts = Options { classify: Classifier::Decide(decider), scopes: vec!["kind".into(), "area".into()], jetstream: "ws://127.0.0.1:9".into(), sweep: Duration::from_secs(600), settle: Duration::ZERO, timeout: Duration::from_secs(10), once: true, discover: false, repos: vec![format!("{}/decided", owner.account.handle)], }; rt.block_on(knotless_triage::run(opts, common::ctx(&bot))) .unwrap(); let labels = |subject: &str| bot.record("dev.knotless.label", &label_rkey(&key, subject)); let set = |subject: &str| -> Vec { let entries = labels(subject)["labels"] .as_array() .cloned() .unwrap_or_default(); (entries.iter()) .filter(|e| e["removed"] != true) .map(|e| e["name"].as_str().unwrap().to_string()) .collect() }; let judged = |subject: &str| { let judged = labels(subject)["judged"].clone(); let said = |k: &str| judged[k].as_str().unwrap().to_string(); (said("model"), said("reason")) }; // Each label it gives at least half a chance; the model that answered, // when the answer names it; every probability, as its reason. assert_eq!(set(&issues[0]), ["kind/bug", "area/cli"]); let reason = "kind/bug 0.91; area/web 0.10; area/cli 0.80"; assert_eq!(judged(&issues[0]), ("clef".into(), reason.into())); // Unsure of the kind, it says none. assert_eq!(set(&issues[1]), ["area/web"]); let reason = "kind/feature 0.30; area/web 0.90; area/cli 0.05"; assert_eq!(judged(&issues[1]), ("jev-1.13.0".into(), reason.into())); assert!(set(&issues[2]).is_empty()); let reason = "kind: none 0.80; area/web 0.00; area/cli 0.00"; assert_eq!(judged(&issues[2]).1, reason); // A model that fails, or answers nothing, sets nothing. for failed in &issues[3..] { assert_eq!(labels(failed), Value::Null); } // What it was asked: one choice of kind, with a way to say none, and // of each area whether it fits; nothing of priority, not asked for. let asked = asked.lock().unwrap(); let crash = asked .iter() .find(|a| a["state"]["title"] == "It crashes") .unwrap(); assert_eq!(crash["model"], "clef"); let questions = crash["questions"].as_object().unwrap(); let keys: Vec<&str> = questions.keys().map(String::as_str).collect(); assert_eq!(keys, ["area_cli", "area_web", "kind"]); assert_eq!(questions["kind"]["type"], "choice"); let criteria = &questions["kind"]["criteria"]; assert_eq!(criteria["bug"], "kind/bug: Something is broken"); assert_eq!(criteria["feature"], "kind/feature"); assert_eq!(criteria["none_of_these"], "None of these fits"); assert_eq!(questions["area_web"]["type"], "noul"); } #[test] fn the_binary() { let bin = env!("CARGO_BIN_EXE_knotless-triage"); let s = Sandbox::new(); let run = |args: &[&str]| { let out = std::process::Command::new(bin) .args(args) .envs(s.vars()) .output() .unwrap(); ( out.status.code(), String::from_utf8_lossy(&out.stderr).to_string(), ) }; let (code, err) = run(&[]); assert_eq!(code, Some(2)); assert!(err.contains("usage: knotless-triage"), "{err}"); // A decision model's token comes from the environment. let (code, err) = run(&[ "--decide", "https://d", "--model", "m", "--scopes", "k", "a/b", ]); assert_eq!(code, Some(2)); assert!( err.contains("--decide takes KNOTLESS_DECISION_TOKEN"), "{err}" ); // Discovery takes an appview; one that does not answer finds nothing. let (code, err) = run(&["--classify", "true", "--scopes", "kind", "--discover"]); assert_eq!(code, Some(1)); assert!( err.contains("--discover finds repositories through an appview"), "{err}" ); let found = std::process::Command::new(bin) .args([ "--classify", "true", "--scopes", "kind", "--discover", "--once", ]) .envs(s.vars()) .env("KNOTLESS_IDENTIFIER", &s.account.handle) .env("KNOTLESS_APPVIEW", "http://127.0.0.1:9") .output() .unwrap(); assert!(found.status.success(), "{found:?}"); let said = String::from_utf8_lossy(&found.stderr); assert!(said.contains("finding repositories failed"), "{said}"); // A repository its owner does not have. let repo = format!("{}/nothing", s.account.handle); let (code, err) = run(&["--classify", "true", "--scopes", "kind", "--once", &repo]); assert_eq!(code, Some(1)); assert!(err.contains("knotless-triage: "), "{err}"); }