johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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}");
}