~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
828 lines · 31.3 KB Raw History
//! 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<Message>,
}

// 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::<Message>();
    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<String> {
        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<String> {
        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<std::sync::Mutex<Vec<Value>>>) {
    use axum::http::{HeaderMap, StatusCode};
    use axum::{Json, extract::State, routing::post};
    type Asked = Arc<std::sync::Mutex<Vec<Value>>>;
    async fn answer(
        State(asked): State<Asked>,
        headers: HeaderMap,
        Json(body): Json<Value>,
    ) -> (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<String> = 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<String> {
        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}");
}