chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

735 lines · 28.2 KB Raw History
//! The MCP server against a reference PDS, driven by an MCP client.

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

use rmcp::ServiceExt;
use rmcp::model::CallToolRequestParams;
use serde_json::{Value, json};

struct Client {
    rt: tokio::runtime::Runtime,
    peer: rmcp::service::RunningService<rmcp::RoleClient, ()>,
}

impl Client {
    fn new(s: &Sandbox) -> Client {
        let rt = tokio::runtime::Runtime::new().unwrap();
        let ctx = ctx(s);
        let peer = rt.block_on(async {
            let (server_io, client_io) = tokio::io::duplex(1 << 20);
            tokio::spawn(async move {
                let running = knotless_mcp::Server::new(ctx)
                    .serve(server_io)
                    .await
                    .unwrap();
                let _ = running.waiting().await;
            });
            ().serve(client_io).await.unwrap()
        });
        Client { rt, peer }
    }

    /// The tool's text, and whether it was an error.
    fn call(&self, tool: &str, args: Value) -> (String, bool) {
        let params = CallToolRequestParams::new(tool.to_string())
            .with_arguments(args.as_object().unwrap().clone());
        let res = self.rt.block_on(self.peer.call_tool(params)).unwrap();
        let text = res.content[0].as_text().unwrap().text.clone();
        (text, res.is_error == Some(true))
    }

    fn ok(&self, tool: &str, args: Value) -> String {
        let (text, err) = self.call(tool, args);
        assert!(!err, "{tool}: {text}");
        text
    }

    fn json(&self, tool: &str, args: Value) -> Value {
        serde_json::from_str(&self.ok(tool, args)).unwrap()
    }
}

/// Logged in as its account, as `knotless login` leaves a machine.
fn logged_in() -> Sandbox {
    let mut s = Sandbox::new();
    s.set("KNOTLESS_PASSWORD", "");
    let password = s.account.password.clone();
    let handle = s.account.handle.clone();
    assert_eq!(
        s.cli(&["login", &handle, "--password-stdin"], password.as_bytes())
            .0,
        0
    );
    s
}

#[test]
fn an_agent_works_on_a_repository() {
    let s = logged_in();
    let c = Client::new(&s);
    let tools = c.rt.block_on(c.peer.list_all_tools()).unwrap();
    let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
    for tool in ["read_file", "commit", "search", "create_repo", "share"] {
        assert!(names.contains(&tool), "{names:?}");
    }

    let created = c.json(
        "create_repo",
        json!({"name": "agentic", "description": "made by a tool"}),
    );
    let repo = created["repo"].as_str().unwrap().to_string();
    assert_eq!(repo, format!("{}/agentic", s.account.handle));
    let (text, err) = c.call("create_repo", json!({"name": "agentic"}));
    assert!(err && text.contains("already exists"), "{text}");

    let first = c.json(
        "commit",
        json!({"repo": repo, "branch": "main", "message": "start", "changes": [
            {"path": "README.md", "content": "# agentic\n\nhello world\n"},
            {"path": "src/main.rs", "content": "fn main() {}\n"},
            {"path": "bin/run", "content": "#!/bin/sh\n", "executable": true},
            {"path": "logo.bin", "base64": "AAEC/w=="},
        ]}),
    );
    assert_eq!(first["changed"], true);
    let second = c.json(
        "commit",
        json!({"repo": format!("knotless://{repo}"), "branch": "main", "message": "tidy", "changes": [
            {"path": "bin/run", "delete": true},
            {"path": "README.md", "content": "# agentic\n\nhello there\n"},
        ], "base": first["sha"]}),
    );
    let (text, err) = c.call(
        "commit",
        json!({"repo": repo, "branch": "main", "message": "x", "changes": [{"path": "a", "content": "x", "delete": true}]}),
    );
    assert!(err && text.contains("exactly one"), "{text}");
    let (text, err) = c.call(
        "commit",
        json!({"repo": repo, "branch": "main", "message": "x", "changes": [{"path": "a", "base64": "!!"}]}),
    );
    assert!(err, "{text}");

    let refs = c.json("refs", json!({"repo": repo}));
    assert_eq!(refs["refs"][0]["sha"], second["sha"]);
    assert_eq!(refs["private"], false);
    let tree = c.json("list_tree", json!({"repo": repo}));
    let names: Vec<&str> = tree
        .as_array()
        .unwrap()
        .iter()
        .map(|e| e["name"].as_str().unwrap())
        .collect();
    assert_eq!(names, ["README.md", "logo.bin", "src"]);
    let sub = c.json(
        "list_tree",
        json!({"repo": repo, "path": "src", "rev": first["sha"]}),
    );
    assert_eq!(sub[0]["name"], "main.rs");
    assert_eq!(
        c.ok("read_file", json!({"repo": repo, "path": "README.md"})),
        "# agentic\n\nhello there\n"
    );
    assert_eq!(
        c.json("read_file", json!({"repo": repo, "path": "logo.bin"}))["base64"],
        "AAEC/w=="
    );
    let hits = c.json("search", json!({"repo": repo, "pattern": "HELLO"}));
    assert_eq!(hits[0]["path"], "README.md");
    assert_eq!(hits[0]["line"], 3);
    let log = c.json("log", json!({"repo": repo, "limit": 5}));
    assert_eq!(log.as_array().unwrap().len(), 2);
    assert_eq!(log[0]["message"], "tidy");
    let touched = c.json("log", json!({"repo": repo, "path": "src"}));
    assert_eq!(touched.as_array().unwrap().len(), 1);
    let diff = c.ok(
        "diff",
        json!({"repo": repo, "base": first["sha"], "head": "main"}),
    );
    assert!(
        diff.contains("-hello world") && diff.contains("+hello there"),
        "{diff}"
    );

    // A new branch starts from the default branch, or from `base`, and
    // its commits are the committer's.
    let feature = c.json("commit", json!({"repo": repo, "branch": "feature", "message": "f", "changes": [{"path": "f", "content": "f\n"}]}));
    assert_eq!(feature["parent"], second["sha"]);
    let fix = c.json("commit", json!({"repo": repo, "branch": "fix", "message": "x", "changes": [{"path": "x", "content": "x\n"}], "base": first["sha"]}));
    assert_eq!(fix["parent"], first["sha"]);
    let log = c.json("log", json!({"repo": repo, "rev": "feature", "limit": 1}));
    assert_eq!(log[0]["author"]["name"], s.account.handle.as_str());

    // Branches are made and deleted without a commit.
    let develop = c.json("create_branch", json!({"repo": repo, "branch": "develop"}));
    assert_eq!(develop["sha"], second["sha"]);
    let old = c.json(
        "create_branch",
        json!({"repo": repo, "branch": "old", "from": first["sha"]}),
    );
    assert_eq!(old["sha"], first["sha"]);
    c.json("delete_branch", json!({"repo": repo, "branch": "old"}));
    c.json(
        "create_branch",
        json!({"repo": repo, "branch": "gone", "from": "main"}),
    );
    c.json("delete_branch", json!({"repo": repo, "branch": "gone"}));
    // A deleted branch can be made again.
    c.json("create_branch", json!({"repo": repo, "branch": "gone"}));
    c.json("delete_branch", json!({"repo": repo, "branch": "gone"}));
    let refs_now = c.json("refs", json!({"repo": repo}))["refs"].to_string();
    assert!(
        refs_now.contains("develop") && !refs_now.contains("refs/heads/old"),
        "{refs_now}"
    );
    for (tool, args, why) in [
        (
            "create_branch",
            json!({"repo": repo, "branch": "develop"}),
            "already has",
        ),
        (
            "create_branch",
            json!({"repo": repo, "branch": "y", "from": "nope"}),
            "no commit or branch",
        ),
        (
            "delete_branch",
            json!({"repo": repo, "branch": "old"}),
            "has no branch",
        ),
        (
            "delete_branch",
            json!({"repo": repo, "branch": "main"}),
            "default branch",
        ),
    ] {
        let (text, err) = c.call(tool, args);
        assert!(err && text.contains(why), "{tool}: {text}");
    }

    let repos = c.json("list_repos", json!({"owner": s.account.handle}));
    assert_eq!(repos[0]["name"], "agentic");
    assert_eq!(repos[0]["description"], "made by a tool");
    let members = c.json("members", json!({"repo": repo}));
    assert_eq!(members[0]["access"], "owner");
    let protect = |extra: Value| {
        let mut args = json!({"repo": repo, "branch": "main"});
        args.as_object_mut()
            .unwrap()
            .extend(extra.as_object().unwrap().clone());
        c.json("protect_branch", args)
    };
    let rule = |history| json!([{"branch": "main", "history": history}]);
    assert_eq!(protect(json!({})), rule("linear"));
    assert_eq!(protect(json!({"merges": true})), rule("merges"));
    assert_eq!(protect(json!({"remove": true})), json!([]));

    // Renamed, it answers to both names.
    let refs = c.json("refs", json!({"repo": repo}));
    let renamed = c.json("rename_repo", json!({"repo": repo, "name": "agentic2"}));
    let moved = format!("knotless://{}/agentic2", s.account.handle);
    assert_eq!(renamed["url"], moved);
    assert_eq!(c.json("refs", json!({"repo": repo}))["refs"], refs["refs"]);
    assert_eq!(c.json("refs", json!({"repo": moved}))["refs"], refs["refs"]);

    // Errors come back as tool errors, for the agent to read.
    for (tool, args) in [
        ("read_file", json!({"repo": repo, "path": "nope"})),
        (
            "read_file",
            json!({"repo": repo, "path": "README.md", "rev": "--all"}),
        ),
        ("list_tree", json!({"repo": "not a repo"})),
        (
            "list_tree",
            json!({"repo": format!("{}/missing", s.account.handle)}),
        ),
        (
            "refs",
            json!({"repo": format!("{}/missing", s.account.handle)}),
        ),
        ("search", json!({"repo": repo, "pattern": ""})),
        ("log", json!({"repo": repo, "rev": "-x"})),
        ("diff", json!({"repo": repo, "base": "-x", "head": "main"})),
        ("list_repos", json!({"owner": "no-such-handle.test"})),
        (
            "members",
            json!({"repo": format!("{}/missing", s.account.handle)}),
        ),
        ("share", json!({"repo": repo, "with": s.account.handle})),
        (
            "protect_branch",
            json!({"repo": repo, "branch": "main", "remove": true}),
        ),
        (
            "protect_branch",
            json!({"repo": format!("{}/missing", s.account.handle), "branch": "main"}),
        ),
        ("rename_repo", json!({"repo": "not a repo", "name": "x"})),
        // With no appview, there is no gateway to assume.
        ("delegate", json!({})),
    ] {
        let (text, err) = c.call(tool, args.clone());
        assert!(err, "{tool} {args}: {text}");
    }
}

#[test]
fn sharing_from_an_agent() {
    if !net().spaces {
        eprintln!("skipped: the PDS is not on the spaces alpha");
        return;
    }
    let s = logged_in();
    let c = Client::new(&s);
    let created = c.json("create_repo", json!({"name": "secret", "private": true}));
    let repo = created["repo"].as_str().unwrap().to_string();
    c.json("commit", json!({"repo": repo, "branch": "main", "message": "m", "changes": [{"path": "a", "content": "a"}]}));
    assert_eq!(c.json("refs", json!({"repo": repo}))["private"], true);
    // Its owner sees it listed, once, under the name it has now.
    let mine = |c: &Client| -> Vec<Value> {
        c.json("list_repos", json!({"owner": s.account.handle}))
            .as_array()
            .unwrap()
            .iter()
            .filter(|r| r["private"] == true)
            .cloned()
            .collect()
    };
    assert_eq!(mine(&c)[0]["name"], "secret");
    c.json("rename_repo", json!({"repo": repo, "name": "hidden"}));
    let listed = mine(&c);
    assert_eq!(listed.len(), 1, "{listed:?}");
    assert_eq!(listed[0]["name"], "hidden");
    c.json(
        "rename_repo",
        json!({"repo": format!("{}/hidden", s.account.handle), "name": "secret"}),
    );
    let reader = logged_in();
    let other = reader.account.clone();
    let members = c.json("share", json!({"repo": repo, "with": other.handle}));
    assert_eq!(
        members[1],
        json!({"did": other.did, "handle": other.handle, "access": "read"})
    );
    // A reader may not list the others.
    let (text, err) = Client::new(&reader).call("members", json!({"repo": repo}));
    assert!(err && text.contains("owner can list"), "{text}");
    let members = c.json(
        "share",
        json!({"repo": repo, "with": other.handle, "revoke": true}),
    );
    assert_eq!(members.as_array().unwrap().len(), 1);
}

#[test]
fn nobody_logged_in() {
    let mut s = Sandbox::new();
    s.set("KNOTLESS_PASSWORD", "");
    let c = Client::new(&s);
    let (text, err) = c.call("create_repo", json!({"name": "x"}));
    assert!(err && text.contains("not logged in"), "{text}");

    // An identifier and password in the environment act as that account.
    let mut s = Sandbox::new();
    let handle = s.account.handle.clone();
    s.set("KNOTLESS_IDENTIFIER", &handle);
    let c = Client::new(&s);
    let created = c.json("create_repo", json!({"name": "from-env"}));
    assert_eq!(created["repo"], format!("{handle}/from-env"));
}

#[test]
fn the_binary_speaks_mcp_on_stdio() {
    use std::io::{BufRead, Write};
    let s = Sandbox::new();
    let mut child = s
        .command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path())
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .unwrap();
    let mut stdin = child.stdin.take().unwrap();
    writeln!(
        stdin,
        "{}",
        json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
            "protocolVersion": "2025-06-18", "capabilities": {},
            "clientInfo": {"name": "t", "version": "0"}}})
    )
    .unwrap();
    let mut line = String::new();
    std::io::BufReader::new(child.stdout.take().unwrap())
        .read_line(&mut line)
        .unwrap();
    let res: Value = serde_json::from_str(&line).unwrap();
    assert_eq!(res["result"]["serverInfo"]["name"], "knotless");
    drop(stdin);
    assert!(child.wait().unwrap().success());

    // A client that leaves before it says hello is an error.
    let out = s
        .command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path())
        .stdin(std::process::Stdio::null())
        .output()
        .unwrap();
    assert!(!out.status.success());
}

/// An appview following the PDS, for listing public repositories' pulls
/// and issues.
/// It takes reports too, and its admin password is `hunter2`.
/// Its OAuth callback is on an https proxy in front of it, which the
/// returned `TlsProxy` keeps up.
fn appview(rt: &tokio::runtime::Runtime) -> (String, Sandbox, TlsProxy) {
    let mut s = Sandbox::new();
    s.set("KNOTLESS_ADMIN_PASSWORD", "hunter2");
    let listener = rt
        .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
        .unwrap();
    let base = format!("http://localhost:{}", listener.local_addr().unwrap().port());
    let index = knotless_serve::index::Index::open(&s.path("index.db")).unwrap();
    let proxy = tls_proxy(listener.local_addr().unwrap().port());
    let mut gateway = knotless_serve::gateway::Gateway::new(&base).unwrap();
    gateway.oauth_url = proxy.url.clone();
    let app = knotless_serve::App::build(
        ctx(&s),
        std::time::Duration::ZERO,
        Some(gateway),
        Some(index),
        false,
    );
    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, s, proxy)
}

#[test]
fn agents_fork_review_and_merge() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let (base, _appview, _proxy) = appview(&rt);
    let mut owner = logged_in();
    owner.set("KNOTLESS_APPVIEW", &base);
    // Delegating is approved in the browser.
    let browser = browser(&owner.account, "");
    owner.set("KNOTLESS_BROWSER", &browser);
    // The appview is a gateway too, at a did:web served over http.
    owner.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let mut contrib = logged_in();
    contrib.set("KNOTLESS_APPVIEW", &base);
    contrib.set("KNOTLESS_DID_WEB_SCHEME", "http");
    let (o, c) = (Client::new(&owner), Client::new(&contrib));

    let repo = c_repo(&o.json("create_repo", json!({"name": "reviewed"})));
    o.json("commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
    let fork = c_repo(&c.json("fork", json!({"repo": repo})));
    assert_eq!(fork, format!("{}/reviewed", contrib.account.handle));
    let change = c.json("commit", json!({"repo": fork, "branch": "main", "message": "b", "changes": [{"path": "b", "content": "b\n"}]}));

    // A collaborator needs the owner's writes delegated to a gateway: the
    // appview, unless another is named.
    let (text, err) = o.call(
        "share",
        json!({"repo": repo, "with": contrib.account.handle, "write": true}),
    );
    assert!(err && text.contains("`delegate` tool"), "{text}");
    let (text, err) = c.call("create_branch", json!({"repo": repo, "branch": "theirs"}));
    assert!(err && text.contains("only its owner can"), "{text}");
    let gateway = o.json("delegate", json!({}))["delegated"].clone();
    assert!(
        gateway
            .as_str()
            .unwrap()
            .starts_with("did:web:localhost%3A"),
        "{gateway}"
    );
    o.json("delegate", json!({"gateway": gateway}));
    o.json(
        "share",
        json!({"repo": repo, "with": contrib.account.handle, "write": true}),
    );
    c.json("create_branch", json!({"repo": repo, "branch": "theirs"}));
    c.json("delete_branch", json!({"repo": repo, "branch": "theirs"}));
    o.json(
        "share",
        json!({"repo": repo, "with": contrib.account.handle, "revoke": true}),
    );

    let pull = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "main", "title": "Add b", "body": "small"}));
    let pull = pull["uri"].as_str().unwrap().to_string();
    let issue = c.json("open_issue", json!({"repo": repo, "title": "Missing c"}))["uri"]
        .as_str()
        .unwrap()
        .to_string();
    let listed = |state: &str| -> Vec<Value> {
        o.json(
            "list_items",
            json!({"repo": repo, "kind": "pull", "state": state}),
        )
        .as_array()
        .unwrap()
        .clone()
    };
    wait_until(|| listed("open").len() == 1);
    // Authors come with their handles.
    assert_eq!(listed("open")[0]["handle"], contrib.account.handle.as_str());
    wait_until(|| {
        o.json("list_items", json!({"repo": repo}))
            .as_array()
            .unwrap()
            .len()
            == 2
    });

    o.json(
        "comment",
        json!({"repo": repo, "uri": pull, "body": "good", "verdict": "approve"}),
    );
    let (text, err) = o.call(
        "comment",
        json!({"repo": repo, "uri": pull, "body": "?", "verdict": "maybe"}),
    );
    assert!(err, "{text}");
    wait_until(|| {
        o.json("show_item", json!({"repo": repo, "uri": pull}))["comments"]
            .as_array()
            .unwrap()
            .len()
            == 1
    });
    let shown = o.json("show_item", json!({"repo": repo, "uri": pull}));
    assert_eq!(shown["item"]["handle"], contrib.account.handle.as_str());
    assert_eq!(shown["item"]["approvals"], 1);

    // The pull's author points it at the head branch it already names.
    assert_eq!(
        c.json(
            "retarget_pull",
            json!({"repo": repo, "uri": pull, "head": "main"})
        )["retargeted"],
        pull
    );
    let (text, err) = o.call(
        "retarget_pull",
        json!({"repo": repo, "uri": pull, "base": "nope"}),
    );
    assert!(err, "{text}");

    // Before merging: what it brings in, that it merges, and the review.
    let shown = o.json("show_item", json!({"repo": repo, "uri": pull}));
    assert_eq!(shown["compare"]["mergeable"], "fast-forward");
    assert_eq!(shown["compare"]["head"], change["sha"]);
    assert_eq!(shown["compare"]["commits"].as_array().unwrap().len(), 1);
    assert_eq!(shown["comments"][0]["body"], "good");
    assert_eq!(shown["comments"][0]["verdict"], "approve");
    assert_eq!(
        shown["comments"][0]["handle"],
        owner.account.handle.as_str()
    );
    let diff = o.ok("pull_diff", json!({"repo": repo, "uri": pull}));
    assert!(diff.contains("+b"), "{diff}");

    assert_eq!(
        o.json("merge", json!({"repo": repo, "uri": pull}))["sha"],
        change["sha"]
    );
    wait_until(|| listed("merged").len() == 1);
    let (text, err) = o.call("merge", json!({"repo": repo, "uri": pull}));
    assert!(err, "{text}");
    let shown = o.json("show_item", json!({"repo": repo, "uri": pull}));
    assert_eq!(shown["compare"]["mergeable"], "merged");

    // A pull that conflicts says where.
    o.json("commit", json!({"repo": repo, "branch": "main", "message": "mine", "changes": [{"path": "a", "content": "owner\n"}]}));
    c.json("commit", json!({"repo": fork, "branch": "clash", "message": "theirs", "changes": [{"path": "a", "content": "contrib\n"}]}));
    let clash = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "clash", "title": "Clash"}));
    let clash = clash["uri"].as_str().unwrap().to_string();
    let compared = &o.json("show_item", json!({"repo": repo, "uri": clash}))["compare"];
    assert_eq!(compared["mergeable"], "conflict");
    assert_eq!(compared["conflicts"], json!(["a"]));
    let (text, err) = o.call("merge", json!({"repo": repo, "uri": clash}));
    assert!(err && text.contains("conflicts in a"), "{text}");
    // One that touches other files merges cleanly though main moved on.
    c.json("commit", json!({"repo": fork, "branch": "aside", "message": "c", "changes": [{"path": "c", "content": "c\n"}]}));
    let aside = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "aside", "title": "Aside"}));
    let aside = aside["uri"].as_str().unwrap().to_string();
    let compared = &o.json("show_item", json!({"repo": repo, "uri": aside}))["compare"];
    assert_eq!(compared["mergeable"], "clean");

    // Renaming a branch takes the pulls on it along.
    o.json("create_branch", json!({"repo": repo, "branch": "develop"}));
    let onto = c.json("open_pull", json!({"repo": repo, "base": "develop", "head_repo": fork, "head_branch": "clash", "title": "Onto develop"}));
    let onto = onto["uri"].as_str().unwrap().to_string();
    wait_until(|| listed("open").iter().any(|i| i["uri"] == onto.as_str()));
    let renamed = o.json(
        "rename_branch",
        json!({"repo": repo, "from": "develop", "to": "next"}),
    );
    assert_eq!(renamed["retargeted"], json!([onto]));
    wait_until(|| {
        o.json("show_item", json!({"repo": repo, "uri": onto}))["item"]["base"] == "next"
    });
    let (text, err) = o.call(
        "rename_branch",
        json!({"repo": repo, "from": "main", "to": "trunk"}),
    );
    assert!(err && text.contains("default branch"), "{text}");

    // A pull whose head is gone still shows, and says so.
    c.json("delete_branch", json!({"repo": fork, "branch": "clash"}));
    let compared = &o.json("show_item", json!({"repo": repo, "uri": clash}))["compare"];
    assert!(
        compared["error"]
            .as_str()
            .unwrap()
            .contains("has no branch clash"),
        "{compared}"
    );

    // An issue has nothing to compare.
    let shown = o.json("show_item", json!({"repo": repo, "uri": issue}));
    assert!(shown.get("compare").is_none(), "{shown}");
    assert_eq!(
        o.json(
            "set_state",
            json!({"repo": repo, "uri": issue, "state": "closed"})
        )["state"],
        "closed"
    );
    let (text, err) = o.call(
        "set_state",
        json!({"repo": repo, "uri": issue, "state": "merged"}),
    );
    assert!(err, "{text}");
    // Offered to the contributor, withdrawn, offered again, and taken up.
    let them = contrib.account.handle.clone();
    o.json("transfer_repo", json!({"repo": repo, "to": them}));
    o.json("transfer_repo", json!({"repo": repo}));
    let offered = o.json(
        "transfer_repo",
        json!({"repo": repo, "to": format!("{them}/kept")}),
    );
    assert_eq!(offered["offeredTo"], format!("knotless://{them}/kept"));
    let moved = c.json("accept_transfer", json!({"repo": repo}));
    assert_eq!(
        moved["url"],
        format!("knotless://{}/kept", contrib.account.did)
    );
    for (tool, args) in [
        ("transfer_repo", json!({"repo": "nobody.test/x", "to": "x"})),
        ("accept_transfer", json!({"repo": "nobody.test/x"})),
        ("fork", json!({"repo": "nobody.test/x"})),
        (
            "open_pull",
            json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "none", "title": "t"}),
        ),
        ("open_issue", json!({"repo": "nobody.test/x", "title": "t"})),
        ("list_items", json!({"repo": "nobody.test/x"})),
        ("pull_diff", json!({"repo": repo, "uri": issue})),
        (
            "show_item",
            json!({"repo": repo, "uri": "at://did:plc:nobody/dev.knotless.issue/x"}),
        ),
    ] {
        let (text, err) = o.call(tool, args.clone());
        assert!(err, "{tool} {args}: {text}");
    }
}

#[test]
fn agents_moderate() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let (base, _appview, _proxy) = appview(&rt);
    let mut owner = logged_in();
    owner.set("KNOTLESS_APPVIEW", &base);
    // Delegating is approved in the browser.
    let browser = browser(&owner.account, "");
    owner.set("KNOTLESS_BROWSER", &browser);
    let mut operator = logged_in();
    operator.set("KNOTLESS_APPVIEW", &base);
    operator.set("KNOTLESS_ADMIN_PASSWORD", "hunter2");
    let (o, c) = (Client::new(&owner), Client::new(&operator));
    let repo = c_repo(&o.json("create_repo", json!({"name": "moderated"})));
    o.json("commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
    let issue = c.json("open_issue", json!({"repo": repo, "title": "Spam"}))["uri"]
        .as_str()
        .unwrap()
        .to_string();
    let items = || {
        o.json("list_items", json!({"repo": repo}))
            .as_array()
            .unwrap()
            .len()
    };
    wait_until(|| items() == 1);

    let hidden = o.json("hide_item", json!({"repo": repo, "uri": issue}));
    assert_eq!(hidden["hidden"], true);
    wait_until(|| items() == 0);
    o.json(
        "hide_item",
        json!({"repo": repo, "uri": issue, "hidden": false}),
    );
    wait_until(|| items() == 1);
    let them = operator.account.handle.clone();
    assert_eq!(
        o.json("block_account", json!({"account": them}))["blocked"],
        true
    );
    wait_until(|| items() == 0);
    o.json("block_account", json!({"account": them, "blocked": false}));
    wait_until(|| items() == 1);
    o.json("block_account", json!({"account": them, "repo": repo}));
    wait_until(|| items() == 0);
    o.json(
        "block_account",
        json!({"account": them, "repo": repo, "blocked": false}),
    );
    wait_until(|| items() == 1);

    let report = c.json(
        "report",
        json!({"subject": repo, "reason": "spam", "text": "all of it"}),
    );
    assert_eq!(report["id"], 1);
    assert_eq!(
        report["subject"]["uri"],
        format!("at://{}/dev.knotless.repo/moderated", owner.account.did)
    );
    c.json(
        "report",
        json!({"subject": owner.account.handle, "reason": "rude"}),
    );
    let reports = c.json("list_reports", json!({}));
    assert_eq!(reports["reports"].as_array().unwrap().len(), 2);
    let down = c.json("takedown", json!({"subject": issue}));
    assert_eq!(down["takedown"]["applied"], true);
    wait_until(|| items() == 0);
    let status = c.json("moderation_status", json!({"subject": issue}));
    assert_eq!(status["takedown"]["applied"], true);
    c.json("takedown", json!({"subject": issue, "applied": false}));
    wait_until(|| items() == 1);

    for (who, tool, args) in [
        (&c, "hide_item", json!({"repo": repo, "uri": issue})),
        (&o, "block_account", json!({"account": "nobody.test"})),
        (&c, "report", json!({"subject": repo, "reason": "boring"})),
        (&o, "takedown", json!({"subject": issue})),
        (&o, "moderation_status", json!({"subject": issue})),
        (&o, "list_reports", json!({})),
    ] {
        let (text, err) = who.call(tool, args.clone());
        assert!(err, "{tool} {args}: {text}");
    }
}

fn c_repo(v: &Value) -> String {
    v["repo"].as_str().unwrap().to_string()
}