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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
747 lines · 29.3 KB Raw History
//! An agent reviewing pulls on the reference PDS: a command, a script
//! here, reviews each open pull that asks the agent's account for a review
//! and that the owner opened, once at each head and only so often, and
//! the review is written as the agent's own comment.

#[path = "../../knotless/tests/common/mod.rs"]
mod common;

use common::{Sandbox, net, wait_until};
use futures::{SinkExt, StreamExt};
use knotless_agent::{Job, Options};
use serde_json::Value;
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.
                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: Value) {
        let payload = serde_json::json!({
            "$type": format!("{KIND}commit"), "seq": 1, "did": did, "time": "2026-10-29T00: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 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
}

/// Reviews by what the pull says: its first round asks for a change on a
/// line, a later one approves, and one stubborn asks every round; one
/// broken fails, one garbled answers
/// nothing readable, one slow takes too long, and one moving waits until
/// the test says go. It notes each pull it is asked about.
const REVIEW: &str = r#"input=$(cat)
printf '%s\n' "$input" >> "$ASKED"
case "$input" in
  *Broken*) exit 3 ;;
  *Garbled*) echo 'not json' ;;
  *Slow*) sleep 5 ;;
  *Moving*)
    touch "$ASKED.started"
    while [ ! -e "$ASKED.go" ]; do sleep 0.1; done
    echo '{"verdict": "approve", "body": "Fine."}' ;;
  *Stubborn*) echo '{"verdict": "changes", "body": "Still not."}' ;;
  *'"round":1'*) echo '{"verdict": "changes", "body": "Name the file.", "lines": [{"line": {"path": "f", "line": 1}, "body": "Say what f is."}]}' ;;
  *) echo '{"verdict": "approve", "body": "Looks right now."}' ;;
esac
"#;

/// How many times the command was asked about a pull 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()
}

/// The last thing the command was given about a pull titled `title`.
fn given(bot: &Sandbox, title: &str) -> Value {
    let asked = std::fs::read_to_string(bot.path("asked")).unwrap();
    let title = format!("\"title\":\"{title}\"");
    let line = asked.lines().rfind(|l| l.contains(&title)).unwrap();
    serde_json::from_str(line).unwrap()
}

/// The reviews `bot` wrote of `pull`, oldest first, and the comments on
/// lines that went with them.
fn reviews(bot: &Sandbox, pull: &str) -> (Vec<Value>, Vec<Value>) {
    let mut said: Vec<(String, Value)> = bot.records("dev.knotless.comment");
    said.retain(|(_, c)| c["subject"] == pull);
    said.sort_by(|a, b| a.0.cmp(&b.0));
    let (lines, reviews): (Vec<_>, Vec<_>) = said
        .into_iter()
        .map(|(_, c)| c)
        .partition(|c| c["line"].is_object());
    (reviews, lines)
}

#[test]
fn an_agent_reviews_pulls() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let base = appview(&rt, &Sandbox::new());
    let (mut owner, mut stranger, mut bot) = (Sandbox::new(), Sandbox::new(), Sandbox::new());
    for s in [&mut owner, &mut stranger, &mut bot] {
        let handle = s.account.handle.clone();
        s.set("KNOTLESS_APPVIEW", &base);
        s.set("KNOTLESS_IDENTIFIER", &handle);
    }
    let me = bot.account.handle.clone();
    std::fs::write(bot.path("review.sh"), REVIEW).unwrap();
    let asked_at = bot.path("asked").to_string_lossy().to_string();
    let command = format!("ASKED={asked_at} sh {}", bot.path("review.sh").display());

    let url = owner.url("proj");
    let work = owner.work("w", 1);
    // What it asks of reviews, on the branch pulls merge into.
    std::fs::create_dir_all(work.join(".knotless")).unwrap();
    owner.commit_file(&work, ".knotless/review.md", "Check the tests.\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    // Each pull brings a branch of its own, one commit past main.
    let branch = |name: &str| {
        let sha = owner.commit_file(&work, "f", &format!("{name}\n"));
        owner.git(
            &work,
            &["push", "-q", &url, &format!("HEAD:refs/heads/{name}")],
        );
        owner.git(&work, &["reset", "-q", "--hard", "HEAD~1"]);
        sha
    };
    let pull = |name: &str, title: &str, more: &[&str]| {
        branch(name);
        let args = [
            &["pull", "create", &url, "main", &url, name, title][..],
            more,
        ]
        .concat();
        ok(&owner, &args)
    };
    let asking = ["--reviewer", me.as_str()];
    let named = pull("named", "Name it", &asking);
    let draft = pull("draft", "Draft one", &[&asking[..], &["--draft"]].concat());
    let unasked = pull("unasked", "Unasked", &[]);
    let failing = [
        pull("broken", "Broken", &asking),
        pull("garbled", "Garbled", &asking),
        pull("slow", "Slow", &asking),
    ];
    let gone = pull("gone", "Gone branch", &asking);
    ok(&owner, &["branch", "delete", &url, "gone"]);
    // A stranger may ask it too, from a fork: it reviews nothing of theirs.
    let fork = ok(&stranger, &["repo", "fork", &url]);
    let theirs = stranger.work("s", 2);
    stranger.git(&theirs, &["push", "-q", &fork, "HEAD:refs/heads/mine"]);
    let args = [
        "pull",
        "create",
        &url,
        "main",
        &fork,
        "mine",
        "From a stranger",
        "--reviewer",
        &me,
    ];
    let strangers = ok(&stranger, &args);
    let all = [&named, &draft, &unasked, &gone, &strangers];
    wait_until(|| {
        let listed = owner.cli(&["pull", "list", &url], b"").1;
        all.iter()
            .copied()
            .chain(&failing)
            .all(|p| listed.contains(p.rsplit('/').next().unwrap()))
    });

    let opts = Options {
        job: Job::Review,
        command: command.clone(),
        all: false,
        rounds: 2,
        timeout: Duration::from_secs(3),
        jetstream: "ws://127.0.0.1:9".into(),
        sweep: Duration::from_secs(600),
        settle: Duration::ZERO,
        once: true,
        repos: vec![format!("{}/proj", owner.account.handle)],
        owners: Vec::new(),
        attempts: 3,
    };
    let once = |opts: &Options| {
        rt.block_on(knotless_agent::run(opts.clone(), common::ctx(&bot)))
            .unwrap()
    };
    once(&opts);
    // The pull that asked it was reviewed, a change asked for on a line,
    // at the head it read.
    let given_named = given(&bot, "Name it");
    assert_eq!(given_named["round"], 1);
    assert_eq!(given_named["rounds"], 2);
    assert_eq!(given_named["changes"], 0);
    assert_eq!(given_named["approved"], Value::Null);
    assert_eq!(given_named["author"], owner.account.handle.as_str());
    assert_eq!(given_named["base"], "main");
    assert_eq!(given_named["head"]["branch"], "named");
    assert_eq!(given_named["earlier"], serde_json::json!([]));
    assert_eq!(given_named["instructions"], "Check the tests.\n");
    let (said, lines) = reviews(&bot, &named);
    assert_eq!(said.len(), 1);
    assert_eq!(said[0]["verdict"], "changes");
    assert_eq!(said[0]["body"], "Name the file.");
    assert_eq!(said[0]["commit"], given_named["head"]["commit"]);
    assert_eq!(lines.len(), 1);
    assert_eq!(lines[0]["body"], "Say what f is.");
    // Nothing that did not ask, or a draft, a stranger's, or one whose
    // branch is gone; and nothing written of what failed.
    for title in ["Draft one", "Unasked", "From a stranger", "Gone branch"] {
        assert_eq!(asked(&bot, title), 0, "{title}");
    }
    for p in &failing {
        assert!(reviews(&bot, p).0.is_empty(), "{p}");
    }
    wait_until(|| ok(&owner, &["pull", "show", &url, &named]).contains("changes"));

    // Read again, by the binary, it reviews only what failed before.
    let ran = std::process::Command::new(env!("CARGO_BIN_EXE_knotless-agent"))
        .args([
            "review",
            "--command",
            &command,
            "--rounds",
            "2",
            "--timeout",
            "3",
        ])
        .args(["--once", &opts.repos[0]])
        .envs(bot.vars())
        .output()
        .unwrap();
    assert!(ran.status.success(), "{ran:?}");
    assert_eq!(asked(&bot, "Name it"), 1);
    assert_eq!(asked(&bot, "Broken"), 2);

    // Pushed to, it is reviewed again, as a second round that knows the
    // first; pushed to once more after it approved, it is reviewed again,
    // from the head it approved: an approval spends none of --rounds.
    let sha = owner.commit_file(&work, "f", "named, as asked\n");
    owner.git(&work, &["push", "-q", "-f", &url, "HEAD:refs/heads/named"]);
    once(&opts);
    let given_named = given(&bot, "Name it");
    assert_eq!(given_named["round"], 2);
    assert_eq!(given_named["changes"], 1);
    assert_eq!(given_named["approved"], Value::Null);
    assert_eq!(given_named["head"]["commit"], sha.as_str());
    assert_eq!(given_named["earlier"][0]["body"], "Name the file.");
    let (said, _) = reviews(&bot, &named);
    assert_eq!(said.len(), 2);
    assert_eq!(said[1]["verdict"], "approve");
    let again = owner.commit_file(&work, "f", "named again\n");
    owner.git(&work, &["push", "-q", "-f", &url, "HEAD:refs/heads/named"]);
    wait_until(|| ok(&owner, &["pull", "show", &url, &named]).contains("approve"));
    once(&opts);
    assert_eq!(asked(&bot, "Name it"), 3);
    let given_named = given(&bot, "Name it");
    assert_eq!(given_named["round"], 3);
    assert_eq!(given_named["changes"], 1);
    assert_eq!(given_named["approved"], sha.as_str());
    assert_eq!(given_named["head"]["commit"], again.as_str());
    owner.git(&work, &["reset", "-q", "--hard", "HEAD~2"]);

    // One it asks changes of at each head stops at --rounds of them.
    let stubborn = pull("stubborn", "Stubborn", &asking);
    wait_until(|| {
        owner
            .cli(&["pull", "list", &url], b"")
            .1
            .contains(stubborn.rsplit('/').next().unwrap())
    });
    once(&opts);
    for n in ["stubborn again\n", "stubborn once more\n"] {
        owner.commit_file(&work, "f", n);
        owner.git(
            &work,
            &["push", "-q", "-f", &url, "HEAD:refs/heads/stubborn"],
        );
        owner.git(&work, &["reset", "-q", "--hard", "HEAD~1"]);
        once(&opts);
    }
    assert_eq!(asked(&bot, "Stubborn"), 2);
    assert_eq!(reviews(&bot, &stubborn).0.len(), 2);

    // A head that only merges the base into the branch changes nothing the
    // pull does: the last review is carried to it, read by no command and
    // counted as no round. A change of its own is then read as round 2.
    let carried = pull("carried", "Carried", &asking);
    wait_until(|| {
        owner
            .cli(&["pull", "list", &url], b"")
            .1
            .contains(carried.rsplit('/').next().unwrap())
    });
    once(&opts);
    assert_eq!(asked(&bot, "Carried"), 1);
    wait_until(|| ok(&owner, &["pull", "show", &url, &carried]).contains("changes"));
    owner.commit_file(&work, "g", "the base moves on\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    owner.git(&work, &["fetch", "-q", &url, "carried"]);
    owner.git(&work, &["merge", "-q", "--no-edit", "FETCH_HEAD"]);
    let merged = owner.git(&work, &["rev-parse", "HEAD"]);
    owner.git(&work, &["push", "-q", &url, "HEAD:refs/heads/carried"]);
    owner.git(&work, &["reset", "-q", "--hard", "HEAD~1"]);
    once(&opts);
    assert_eq!(asked(&bot, "Carried"), 1);
    let (said, lines) = reviews(&bot, &carried);
    assert_eq!(said.len(), 2);
    assert_eq!(said[1]["verdict"], "changes");
    assert_eq!(said[1]["commit"], merged.as_str());
    assert!(
        said[1]["body"]
            .as_str()
            .unwrap()
            .starts_with("Carried forward:")
    );
    assert_eq!(lines.len(), 1);
    // Read again at the same head, it writes nothing more.
    wait_until(|| ok(&owner, &["pull", "show", &url, &carried]).contains(&merged[..12]));
    once(&opts);
    assert_eq!(reviews(&bot, &carried).0.len(), 2);
    let fixed = owner.commit_file(&work, "f", "carried, as asked\n");
    owner.git(
        &work,
        &["push", "-q", "-f", &url, "HEAD:refs/heads/carried"],
    );
    once(&opts);
    assert_eq!(asked(&bot, "Carried"), 2);
    let given_carried = given(&bot, "Carried");
    assert_eq!(given_carried["round"], 2);
    assert_eq!(given_carried["head"]["commit"], fixed.as_str());
    assert_eq!(given_carried["earlier"].as_array().unwrap().len(), 1);
    assert_eq!(given_carried["earlier"][0]["body"], "Name the file.");
    owner.git(&work, &["reset", "-q", "--hard", "HEAD~1"]);

    // Asked for changes, a pull whose author changes what it says and
    // then merges the base in is read again at that merge, as a round: the
    // request may be answered, so it is not carried.
    let edited = pull("edited", "Edited", &asking);
    wait_until(|| {
        owner
            .cli(&["pull", "list", &url], b"")
            .1
            .contains(edited.rsplit('/').next().unwrap())
    });
    once(&opts);
    wait_until(|| ok(&owner, &["pull", "show", &url, &edited]).contains("changes"));
    ok(
        &owner,
        &["comment", "edit", &url, &edited, "-m", "Answered in here."],
    );
    wait_until(|| ok(&owner, &["pull", "show", &url, &edited]).contains("Answered in here"));
    owner.commit_file(&work, "g", "the base moves on again\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    owner.git(&work, &["fetch", "-q", &url, "edited"]);
    owner.git(&work, &["merge", "-q", "--no-edit", "FETCH_HEAD"]);
    let merged = owner.git(&work, &["rev-parse", "HEAD"]);
    owner.git(&work, &["push", "-q", &url, "HEAD:refs/heads/edited"]);
    owner.git(&work, &["reset", "-q", "--hard", "HEAD~1"]);
    once(&opts);
    assert_eq!(asked(&bot, "Edited"), 2);
    let given_edited = given(&bot, "Edited");
    assert_eq!(given_edited["round"], 2);
    assert_eq!(given_edited["head"]["commit"], merged.as_str());
    let (said, _) = reviews(&bot, &edited);
    assert_eq!(said.len(), 2);
    assert_eq!(said[1]["verdict"], "approve");

    // Asked for changes, a pull whose author then changes what it says is
    // read again at the same head, as a round; once approved, an edit is
    // read no more.
    let answered = pull("answered", "Answered", &asking);
    wait_until(|| {
        owner
            .cli(&["pull", "list", &url], b"")
            .1
            .contains(answered.rsplit('/').next().unwrap())
    });
    once(&opts);
    assert_eq!(asked(&bot, "Answered"), 1);
    let head = given(&bot, "Answered")["head"]["commit"].clone();
    wait_until(|| ok(&owner, &["show", &url, &answered]).contains("changes"));
    once(&opts);
    assert_eq!(asked(&bot, "Answered"), 1);
    ok(
        &owner,
        &[
            "comment",
            "edit",
            &url,
            &answered,
            "-m",
            "The file is named here.",
        ],
    );
    wait_until(|| ok(&owner, &["pull", "show", &url, &answered]).contains("named here"));
    once(&opts);
    assert_eq!(asked(&bot, "Answered"), 2);
    let given_answered = given(&bot, "Answered");
    assert_eq!(given_answered["round"], 2);
    assert_eq!(given_answered["head"]["commit"], head);
    assert_eq!(given_answered["body"], "The file is named here.");
    assert_eq!(given_answered["earlier"][0]["commit"], head);
    let (said, _) = reviews(&bot, &answered);
    assert_eq!(said[1]["verdict"], "approve");
    wait_until(|| ok(&owner, &["pull", "show", &url, &answered]).contains("approve"));
    ok(
        &owner,
        &["comment", "edit", &url, &answered, "-m", "Said again."],
    );
    wait_until(|| ok(&owner, &["pull", "show", &url, &answered]).contains("Said again"));
    once(&opts);
    assert_eq!(asked(&bot, "Answered"), 2);

    // One pushed to as it is read is read again at its new head.
    let moving = pull("moving", "Moving", &asking);
    wait_until(|| {
        owner
            .cli(&["pull", "list", &url], b"")
            .1
            .contains(moving.rsplit('/').next().unwrap())
    });
    let patient = Options {
        timeout: Duration::from_secs(60),
        ..opts.clone()
    };
    let reading = rt.spawn(knotless_agent::run(patient.clone(), common::ctx(&bot)));
    wait_until(|| bot.path("asked.started").exists());
    let moved = owner.commit_file(&work, "f", "moved\n");
    owner.git(&work, &["push", "-q", "-f", &url, "HEAD:refs/heads/moving"]);
    std::fs::write(bot.path("asked.go"), "").unwrap();
    rt.block_on(reading).unwrap().unwrap();
    assert!(reviews(&bot, &moving).0.is_empty());
    once(&patient);
    let (said, _) = reviews(&bot, &moving);
    assert_eq!(said.len(), 1);
    assert_eq!(said[0]["commit"], moved.as_str());
    owner.git(&work, &["reset", "-q", "--hard", "HEAD~1"]);

    // 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_agent::run(watch.clone(), common::ctx(&bot)));
    let swept = pull("swept", "Swept", &asking);
    wait_until(|| reviews(&bot, &swept).0.len() == 1);
    sweeping.abort();

    // With Jetstream, it reads its repositories when a pull about one is
    // written, or a ref by one whose pull asks it: here, one now gone,
    // which it goes on past.
    let gone_repo = owner.url("gone");
    ok(&owner, &["repo", "create", &gone_repo]);
    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, "Broken");
    rt.spawn(knotless_agent::run(watch, common::ctx(&bot)));
    // Connecting, it reads them all.
    wait_until(|| asked(&bot, "Broken") > before);
    ok(&owner, &["repo", "delete", &gone_repo, "--yes"]);
    let heard = pull("heard", "Heard", &asking);
    wait_until(|| {
        owner
            .cli(&["pull", "list", &url], b"")
            .1
            .contains(heard.rsplit('/').next().unwrap())
    });
    let rkey = heard.rsplit('/').next().unwrap();
    let record = owner.record("dev.knotless.pull", rkey);
    let mut elsewhere = record.clone();
    elsewhere["repo"]["key"] = "3zzzzzzzzzzzz".into();
    let did = owner.account.did.clone();
    js.send(&did, "dev.knotless.pull", rkey, elsewhere);
    js.send(&did, "dev.knotless.check", rkey, record.clone());
    js.send(&did, "dev.knotless.pull", rkey, record);
    wait_until(|| reviews(&bot, &heard).0.len() == 1);
    wait_until(|| ok(&owner, &["pull", "show", &url, &heard]).contains("changes"));
    owner.commit_file(&work, "f", "heard again\n");
    owner.git(&work, &["push", "-q", "-f", &url, "HEAD:refs/heads/heard"]);
    js.send(&did, "dev.knotless.ref", "heard", serde_json::json!({}));
    wait_until(|| reviews(&bot, &heard).0.len() == 2);

    wait_until(|| ok(&owner, &["pull", "show", &url, &heard]).contains("approve"));
    // With --all, it reviews the owner's pulls that did not ask it too,
    // but still no draft.
    let all = Options {
        all: true,
        ..opts.clone()
    };
    once(&all);
    assert_eq!(asked(&bot, "Unasked"), 1);
    assert_eq!(reviews(&bot, &unasked).0.len(), 1);
    assert_eq!(asked(&bot, "Draft one"), 0);

    // With --owner, it watches every repository the owner holds, one made
    // after it started too: a repository record the owner writes sends it
    // to list them again. An owner it cannot list is passed by.
    let js = jetstream(&rt);
    let owners = Options {
        once: false,
        jetstream: js.url.clone(),
        sweep: Duration::from_secs(600),
        repos: Vec::new(),
        owners: vec![
            "did:web:nobody.invalid".into(),
            owner.account.handle.clone(),
        ],
        ..opts.clone()
    };
    let before = asked(&bot, "Broken");
    rt.spawn(knotless_agent::run(owners, common::ctx(&bot)));
    wait_until(|| asked(&bot, "Broken") > before);
    let later = owner.url("later");
    owner.git(&work, &["push", "-q", &later, "main"]);
    owner.commit_file(&work, "f", "later\n");
    owner.git(&work, &["push", "-q", &later, "HEAD:refs/heads/late"]);
    owner.git(&work, &["reset", "-q", "--hard", "HEAD~1"]);
    let args = ["pull", &later, "main", &later, "late", "Late", "--reviewer"];
    let late = ok(&owner, &[&args[..], &[me.as_str()]].concat());
    let rkey = late.rsplit('/').next().unwrap();
    wait_until(|| owner.cli(&["pull", "list", &later], b"").1.contains(rkey));
    js.send(&did, "dev.knotless.repo", "later", serde_json::json!({}));
    wait_until(|| reviews(&bot, &late).0.len() == 1);
    // A pull about any of the owner's repositories rings for it too.
    wait_until(|| ok(&owner, &["pull", "show", &later, &late]).contains("changes"));
    owner.commit_file(&work, "f", "later again\n");
    owner.git(&work, &["push", "-q", "-f", &later, "HEAD:refs/heads/late"]);
    owner.git(&work, &["reset", "-q", "--hard", "HEAD~1"]);
    let record = owner.record("dev.knotless.pull", rkey);
    js.send(&did, "dev.knotless.pull", rkey, record);
    wait_until(|| reviews(&bot, &late).0.len() == 2);
}

/// A private repository's pulls are reviewed as a public one's, once the
/// owner shares it with the agent's account: it reads them, and what the
/// repository asks of reviews, as itself. These need a PDS on the spaces
/// alpha, and pass trivially without one.
#[test]
fn an_agent_reviews_a_private_repositorys_pulls() {
    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();
    let owned = owner.account.handle.clone();
    owner.set("KNOTLESS_APPVIEW", &base);
    owner.set("KNOTLESS_IDENTIFIER", &owned);
    // It reads as itself, logged in, as a space credential needs.
    let mut bot = Sandbox::new();
    bot.set("KNOTLESS_PASSWORD", "");
    bot.set("KNOTLESS_APPVIEW", &base);
    let me = bot.account.handle.clone();
    let password = bot.account.password.clone();
    let login = bot.cli(&["login", &me, "--password-stdin"], password.as_bytes());
    assert_eq!(login.0, 0, "{}", login.1);
    std::fs::write(bot.path("review.sh"), REVIEW).unwrap();
    let asked_at = bot.path("asked").to_string_lossy().to_string();
    let command = format!("ASKED={asked_at} sh {}", bot.path("review.sh").display());

    let url = owner.url("hidden");
    ok(
        &owner,
        &["repo", "create", &url, "--private", "--no-runner"],
    );
    ok(&owner, &["repo", "share", &url, &me]);
    let work = owner.work("w", 1);
    std::fs::create_dir_all(work.join(".knotless")).unwrap();
    owner.commit_file(&work, ".knotless/review.md", "Keep it secret.\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    owner.commit_file(&work, "f", "hidden\n");
    owner.git(&work, &["push", "-q", &url, "HEAD:refs/heads/x"]);
    let pull = ok(
        &owner,
        &[
            "pull",
            "create",
            &url,
            "main",
            &url,
            "x",
            "Hidden change",
            "--reviewer",
            &me,
        ],
    );
    wait_until(|| {
        bot.cli(&["pull", "list", &url], b"")
            .1
            .contains(pull.rsplit('/').next().unwrap())
    });

    let opts = Options {
        job: Job::Review,
        command,
        all: false,
        rounds: 2,
        timeout: Duration::from_secs(10),
        jetstream: "ws://127.0.0.1:9".into(),
        sweep: Duration::from_secs(600),
        settle: Duration::ZERO,
        once: true,
        repos: vec![format!("{owned}/hidden")],
        owners: Vec::new(),
        attempts: 3,
    };
    rt.block_on(knotless_agent::run(opts.clone(), common::ctx(&bot)))
        .unwrap();
    let read = given(&bot, "Hidden change");
    assert_eq!(read["instructions"], "Keep it secret.\n");
    // Its review is kept with the repository's comments, which the owner
    // reads.
    wait_until(|| ok(&owner, &["pull", "show", &url, &pull]).contains("Name the file."));

    // With --owner alone it finds the repository too: sharing it left the
    // agent an invite at the appview, which no listing of the owner's PDS
    // would show.
    owner.commit_file(&work, "g", "hidden again\n");
    owner.git(&work, &["push", "-q", &url, "HEAD:refs/heads/y"]);
    let args = ["pull", "create", &url, "main", &url, "y", "Found by owner"];
    let found = ok(&owner, &[&args[..], &["--reviewer", &me]].concat());
    wait_until(|| {
        bot.cli(&["pull", "list", &url], b"")
            .1
            .contains(found.rsplit('/').next().unwrap())
    });
    let owners = Options {
        repos: Vec::new(),
        owners: vec![owned.clone()],
        ..opts
    };
    rt.block_on(knotless_agent::run(owners, common::ctx(&bot)))
        .unwrap();
    assert_eq!(
        given(&bot, "Found by owner")["instructions"],
        "Keep it secret.\n"
    );
}

#[test]
fn the_binary() {
    let bin = env!("CARGO_BIN_EXE_knotless-agent");
    let s = Sandbox::new();
    let run = |args: &[&str]| {
        let out = std::process::Command::new(bin)
            .args(args)
            .envs(s.vars())
            .env("KNOTLESS_IDENTIFIER", &s.account.handle)
            .output()
            .unwrap();
        let err = String::from_utf8_lossy(&out.stderr).to_string();
        (out.status.code(), err)
    };
    let (code, err) = run(&[]);
    assert_eq!(code, Some(2));
    assert!(err.contains("usage: knotless-agent review"), "{err}");
    // A repository its owner does not have.
    let repo = format!("{}/nothing", s.account.handle);
    let (code, err) = run(&["review", "--command", "true", "--once", &repo]);
    assert_eq!(code, Some(1));
    assert!(err.contains("knotless-agent: "), "{err}");
}