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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
3975 lines · 156.4 KB Raw History
//! Forks, pulls, issues and reviews, with an appview that follows the
//! reference PDS's firehose.

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

use futures::{SinkExt, StreamExt};
use knotless_serve::gateway::Gateway;
use knotless_serve::index::Index;
use knotless_serve::{App, router};
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;

struct Appview {
    base: String,
    did: String,
    app: Arc<App>,
    rt: tokio::runtime::Runtime,
    _proxy: TlsProxy,
    _sandbox: Sandbox,
}

/// An appview that is a gateway too, following the PDS.
fn appview() -> Appview {
    appview_following(true, "")
}

/// An appview that is a gateway too, following the PDS or, for one whose
/// index never catches up, not, and run by the accounts `operators` names.
fn appview_following(firehose: bool, operators: &str) -> Appview {
    let mut s = Sandbox::new();
    s.set("KNOTLESS_PASSWORD", "");
    s.set("KNOTLESS_DID_WEB_SCHEME", "http");
    s.set("KNOTLESS_ADMIN_DIDS", operators);
    let rt = tokio::runtime::Runtime::new().unwrap();
    let listener = rt
        .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
        .unwrap();
    let port = listener.local_addr().unwrap().port();
    let base = format!("http://localhost:{port}");
    let mut gw = Gateway::new(&base).unwrap();
    // Authorization servers reach a gateway, an OAuth client, over https.
    let proxy = tls_proxy(port);
    gw.oauth_url = proxy.url.clone();
    let did = gw.did.clone();
    let index = Index::open(&s.path("index.db")).unwrap();
    let app = App::build(ctx(&s), Duration::ZERO, Some(gw), Some(index));
    let router = router(app.clone());
    rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
    if firehose {
        let ws = net().pds.replace("http://", "ws://");
        rt.spawn(knotless_serve::firehose::watch(app.clone(), ws));
    }
    Appview {
        base,
        did,
        app,
        rt,
        _proxy: proxy,
        _sandbox: s,
    }
}

fn person(account: Account, appview: &Appview) -> Sandbox {
    let mut s = Sandbox::with(account);
    s.set("KNOTLESS_PASSWORD", "");
    s.set("KNOTLESS_DID_WEB_SCHEME", "http");
    s.set("KNOTLESS_APPVIEW", &appview.base);
    s.set("KNOTLESS_BROWSER", &browser(&s.account.clone(), ""));
    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
}

fn ok(s: &Sandbox, args: &[&str]) -> String {
    let (code, out) = s.cli(args, b"");
    assert_eq!(code, 0, "{args:?}: {out}");
    out
}

fn fails(s: &Sandbox, args: &[&str]) {
    let (code, out) = s.cli(args, b"");
    assert_eq!(code, 1, "{args:?} should fail: {out}");
}

/// Waits until `list` shows `line`.
#[track_caller]
fn listed(s: &Sandbox, url: &str, line: &str) {
    wait_until(|| both(s, url).1.contains(line));
}

/// What `pull list` and `issue list` print together, and whether both
/// answered.
fn both(s: &Sandbox, url: &str) -> (bool, String) {
    let (pulls, pulled) = s.cli(&["pull", "list", url], b"");
    let (issues, issued) = s.cli(&["issue", "list", url], b"");
    (pulls == 0 && issues == 0, format!("{pulled}{issued}"))
}

/// A stand-in for a PDS's account list, of `dids` alone, as one that
/// lists no accounts by collection: the records are read from their PDS.
fn listing(av: &Appview, dids: &[&str]) -> String {
    let repos: Vec<Value> = dids.iter().map(|d| serde_json::json!({"did": d})).collect();
    let page = axum::Json(serde_json::json!({ "repos": repos }));
    let listed = axum::routing::get(move || async move { page });
    let router = axum::Router::new().route("/xrpc/com.atproto.sync.listRepos", listed);
    av.rt.block_on(async move {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let url = format!("http://{}", listener.local_addr().unwrap());
        tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
        url
    })
}

/// A mirror, as hubble is one, that answers `getRepo` with the CAR the
/// account's PDS has, and counts what it was asked.
fn mirror(av: &Appview) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
    let asked = Arc::new(std::sync::atomic::AtomicUsize::new(0));
    let count = asked.clone();
    let get_repo = axum::routing::get(
        move |q: axum::extract::Query<std::collections::HashMap<String, String>>| {
            count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            async move {
                let did = q.get("did").cloned().unwrap_or_default();
                let url = format!("{}/xrpc/com.atproto.sync.getRepo", net().pds);
                let res = reqwest::Client::new().get(url).query(&[("did", did)]);
                let car = res.send().await.unwrap().bytes().await.unwrap();
                ([("content-type", "application/vnd.ipld.car")], car.to_vec())
            }
        },
    );
    let router = axum::Router::new().route("/xrpc/com.atproto.sync.getRepo", get_repo);
    let url = av.rt.block_on(async move {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let url = format!("http://{}", listener.local_addr().unwrap());
        tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
        url
    });
    (url, asked)
}

#[test]
fn forks_pulls_reviews_and_merges() {
    let av = appview();
    let owner = person(account(), &av);
    let contrib = person(account(), &av);
    let url = owner.url("forkable");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);

    // Never over a repository of the contributor's by its name.
    ok(&contrib, &["repo", "create", &contrib.url("taken")]);
    let theirs = contrib.record("dev.knotless.repo", "taken");
    fails(&contrib, &["repo", "fork", &url, "taken"]);
    assert_eq!(contrib.record("dev.knotless.repo", "taken"), theirs);

    // A fork in the contributor's account, with a branch to offer.
    let fork = ok(&contrib, &["repo", "fork", &url]);
    assert_eq!(fork, format!("knotless://{}/forkable", contrib.account.did));
    let clone = contrib.path("clone");
    contrib.git(contrib.dir.path(), &["clone", "-q", &fork, "clone"]);
    contrib.git(&clone, &["checkout", "-q", "-b", "feature"]);
    let feature = contrib.commit_file(&clone, "b", "from the fork\n");
    contrib.git(&clone, &["push", "-q", "origin", "feature"]);

    fails(
        &contrib,
        &[
            "pull",
            "create",
            &url,
            "main",
            &fork,
            "no-such-branch",
            "nope",
        ],
    );
    let pull = ok(
        &contrib,
        &[
            "pull", "create", &url, "main", &fork, "feature", "Add b", "-m", "please",
        ],
    );
    assert!(pull.starts_with(&format!("at://{}/dev.knotless.pull/", contrib.account.did)));
    listed(&owner, &url, &format!("pull\topen\t{pull}\tAdd b"));

    // Its author comments; the owner reviews.
    fails(
        &contrib,
        &["comment", "add", &url, &pull, "mine", "--approve"],
    );
    ok(&contrib, &["comment", "add", &url, &pull, "please look"]);
    ok(
        &owner,
        &[
            "comment",
            "add",
            &url,
            &pull,
            "looks right to me",
            "--approve",
        ],
    );
    ok(&owner, &["comment", "add", &url, &pull, "thanks"]);
    fails(&owner, &["comment", "add", &url, &pull, "?", "--bogus"]);
    wait_until(|| {
        owner
            .cli(&["pull", "show", &url, &pull], b"")
            .1
            .contains("thanks")
    });
    let shown = ok(&owner, &["pull", "show", &url, &pull]);
    assert!(shown.starts_with("open pull by"), "{shown}");
    assert!(
        shown.contains("please") && shown.contains("[approve]: looks right"),
        "{shown}"
    );
    let review = format!(" approve at {}\n", &feature[..12]);
    assert!(
        shown.contains("review: ") && shown.contains(&review),
        "{shown}"
    );

    // The base has not moved: a merge commit all the same, naming the
    // pull, on main as it was and the head.
    let before = owner.git(&work, &["rev-parse", "HEAD"]);
    let merged = ok(&owner, &["pull", "merge", &url, &pull]);
    assert_ne!(merged, feature);
    let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
    assert!(
        refs.contains(&format!("{merged}\trefs/heads/main")),
        "{refs}"
    );
    owner.git(&work, &["fetch", "-q", &url, "main"]);
    let made = owner.git(&work, &["log", "-1", "--format=%P%n%B", "FETCH_HEAD"]);
    assert!(made.starts_with(&format!("{before} {feature}\n")), "{made}");
    assert!(made.trim_end().ends_with(pull.trim()), "{made}");
    listed(&owner, &url, &format!("pull\tmerged\t{pull}"));
    fails(&owner, &["pull", "merge", &url, &pull]);
    // Merged, it is not reopened: a revert undoes it.
    let (reopened, _, err) = owner.knotless(&["pull", "reopen", &url, &pull], b"");
    assert!(!reopened && err.contains("is merged"), "{err}");

    // The base moved: a merge commit with both parents.
    owner.git(&work, &["pull", "-q", &url, "main"]);
    owner.commit_file(&work, "c", "owner's\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    contrib.git(&clone, &["checkout", "-q", "-b", "two", &feature]);
    let two = contrib.commit_file(&clone, "d", "more\n");
    contrib.git(&clone, &["push", "-q", "origin", "two"]);
    let second = ok(
        &contrib,
        &["pull", "create", &url, "main", &fork, "two", "Add d"],
    );
    listed(&owner, &url, &second);
    let merge = ok(&owner, &["pull", "merge", &url, &second]);
    let check = owner.path("check");
    owner.git(owner.dir.path(), &["clone", "-q", &url, "check"]);
    assert_eq!(owner.git(&check, &["rev-parse", "HEAD"]), merge);
    assert_eq!(owner.git(&check, &["rev-parse", "HEAD^2"]), two);
    assert!(check.join("c").exists() && check.join("d").exists());

    // Both sides changed one file: a conflict, and nothing moves.
    owner.git(&work, &["pull", "-q", &url, "main"]);
    owner.commit_file(&work, "f", "owner's f\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    contrib.git(&clone, &["checkout", "-q", "-b", "clash", &two]);
    contrib.commit_file(&clone, "f", "contributor's f\n");
    contrib.git(&clone, &["push", "-q", "origin", "clash"]);
    let clash = ok(
        &contrib,
        &["pull", "create", &url, "main", &fork, "clash", "Change f"],
    );
    listed(&owner, &url, &clash);
    let (merged, _, err) = owner.knotless(&["pull", "merge", &url, &clash], b"");
    assert!(!merged && err.contains("conflicts in f"), "{err}");
    // Asked first, it says so, and that its author may only retarget it.
    let checked = ok(&contrib, &["pull", "merge", &url, &clash, "--check"]);
    let said = format!(
        "conflicting\tmerge\tchecks none\nconflict\tf\nmay\tbase,head\nmerging conflicts in f; and you may not merge it: only the owner and collaborators of {url} merge its pulls"
    );
    assert_eq!(checked, said);

    // Branches can go between opening a pull and merging it.
    contrib.git(&clone, &["push", "-q", "origin", ":clash"]);
    let (merged, _, err) = owner.knotless(&["pull", "merge", &url, &clash], b"");
    assert!(!merged && err.ends_with("has no branch clash"), "{err}");
    fails(
        &contrib,
        &[
            "pull",
            "create",
            &url,
            "release",
            &fork,
            "two",
            "Into release",
        ],
    );
    owner.git(&work, &["push", "-q", &url, "main:release"]);
    let release = ok(
        &contrib,
        &[
            "pull",
            "create",
            &url,
            "release",
            &fork,
            "two",
            "Into release",
        ],
    );
    owner.git(&work, &["push", "-q", &url, ":release"]);
    listed(&owner, &url, &release);
    let (merged, _, err) = owner.knotless(&["pull", "merge", &url, &release], b"");
    assert!(!merged && err.ends_with("has no branch release"), "{err}");

    // A pull outlives its branches by pointing at others: the target by
    // anyone who may close it, the head by its author alone.
    owner.git(&work, &["push", "-q", &url, "main:stable"]);
    for bad in [
        vec!["pull", "retarget", &url, &release],
        vec!["pull", "retarget", &url, &release, "--sideways", "x"],
        vec!["pull", "retarget", &url, &release, "--base", "nope"],
        vec!["pull", "retarget", &url, &release, "--head", "two"],
        vec!["pull", "retarget", &url, &pull, "--base", "stable"],
    ] {
        fails(&owner, &bad);
    }
    ok(
        &owner,
        &["pull", "retarget", &url, &release, "--base", "stable"],
    );
    // One whose head cannot change, as it is not the owner's pull, moves
    // its base nowhere either: it still merges into stable below.
    let both = [
        "pull", "retarget", &url, &release, "--base", "main", "--head", "two",
    ];
    fails(&owner, &both);
    fails(
        &contrib,
        &["pull", "retarget", &url, &clash, "--head", "nope"],
    );
    ok(
        &contrib,
        &["pull", "retarget", &url, &clash, "--head", "two"],
    );
    let issue = ok(&contrib, &["issue", "create", &url, "Not a pull"]);
    fails(
        &owner,
        &["pull", "retarget", &url, &issue, "--base", "main"],
    );
    // Once the index has the new target, the pull merges into it.
    wait_until(|| owner.knotless(&["pull", "merge", &url, &release], b"").0);
    let check = owner.path("stable");
    owner.git(
        owner.dir.path(),
        &["clone", "-q", "-b", "stable", &url, "stable"],
    );
    owner.git(&check, &["merge-base", "--is-ancestor", &two, "stable"]);

    // A collaborator merges, through the gateway.
    ok(
        &owner,
        &["account", "delegate", &owner.account.handle, &av.did],
    );
    ok(
        &owner,
        &["repo", "share", &url, &contrib.account.handle, "--write"],
    );
    contrib.git(&clone, &["fetch", "-q", &url, "main"]);
    contrib.git(&clone, &["checkout", "-q", "-b", "three", &merge]);
    contrib.commit_file(&clone, "e", "collaborating\n");
    contrib.git(&clone, &["push", "-q", "origin", "three"]);
    let third = ok(
        &contrib,
        &["pull", "create", &url, "main", &fork, "three", "Add e"],
    );
    listed(&contrib, &url, &third);
    let sha = ok(&contrib, &["pull", "merge", &url, &third]);
    let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
    assert!(refs.contains(&format!("{sha}\trefs/heads/main")), "{refs}");
    // And makes and deletes branches the same way, as the owner does.
    assert_eq!(ok(&contrib, &["branch", "create", &url, "topic"]), sha);
    assert_eq!(
        ok(&contrib, &["branch", "delete", &url, "topic"]),
        "deleted topic"
    );
    assert_eq!(ok(&owner, &["branch", "create", &url, "pinned", &two]), two);
    ok(&owner, &["branch", "delete", &url, "pinned"]);

    // A branch in the repository itself goes with its merged pull, and
    // pulls based on it move to its base, unless another open pull still
    // brings it in, or it is asked to stay.
    owner.git(&work, &["pull", "-q", &url, "main"]);
    owner.git(&work, &["checkout", "-q", "-b", "tidy"]);
    owner.commit_file(&work, "g", "tidy\n");
    owner.git(&work, &["checkout", "-q", "-b", "atop"]);
    owner.commit_file(&work, "h", "atop\n");
    owner.git(&work, &["push", "-q", &url, "tidy", "atop", "tidy:kept"]);
    let tidy = ok(
        &owner,
        &["pull", "create", &url, "main", &url, "tidy", "Tidy"],
    );
    let atop = ok(
        &owner,
        &["pull", "create", &url, "tidy", &url, "atop", "Atop"],
    );
    let kept = ok(
        &owner,
        &["pull", "create", &url, "main", &url, "kept", "Kept"],
    );
    listed(&owner, &url, &atop);
    listed(&owner, &url, &kept);
    let merged = ok(&owner, &["pull", "merge", &url, &tidy]);
    assert!(merged.ends_with("\ndeleted tidy"), "{merged}");
    // Its base gone, it merges only for having moved to main.
    let merged = ok(&owner, &["pull", "merge", &url, &atop]);
    assert!(merged.ends_with("\ndeleted atop"), "{merged}");
    owner.git(&work, &["fetch", "-q", &url, "main"]);
    owner.git(&work, &["cat-file", "-e", "FETCH_HEAD:h"]);
    fails(&owner, &["pull", "merge", &url, &kept, "--bogus"]);
    let merged = ok(&owner, &["pull", "merge", &url, &kept, "--keep-branch"]);
    assert!(!merged.contains("deleted"), "{merged}");
    let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
    assert!(refs.contains("refs/heads/kept"), "{refs}");
    assert!(
        !refs.contains("refs/heads/tidy") && !refs.contains("refs/heads/atop"),
        "{refs}"
    );
    // A protected head branch is not deleted, as a gateway would refuse it.
    owner.git(&work, &["push", "-q", &url, "atop:guarded"]);
    ok(&owner, &["branch", "protect", &url, "guarded"]);
    let guarded = ok(
        &owner,
        &["pull", "create", &url, "main", &url, "guarded", "Guarded"],
    );
    listed(&owner, &url, &guarded);
    let merged = ok(&owner, &["pull", "merge", &url, &guarded]);
    assert!(!merged.contains("deleted"), "{merged}");
    let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
    assert!(refs.contains("refs/heads/guarded"), "{refs}");

    // On a linear branch, squashing the pull below leaves the one above
    // holding its original commits, which stay out of what it changes.
    owner.git(&work, &["pull", "-q", &url, "main"]);
    owner.git(&work, &["checkout", "-q", "-b", "trunk"]);
    owner.git(&work, &["checkout", "-q", "-b", "low"]);
    owner.commit_file(&work, "low", "low\n");
    owner.git(&work, &["checkout", "-q", "-b", "mid"]);
    owner.commit_file(&work, "mid", "mid\n");
    owner.git(&work, &["checkout", "-q", "-b", "top"]);
    owner.commit_file(&work, "top", "top\n");
    owner.git(&work, &["push", "-q", &url, "trunk", "low", "mid", "top"]);
    ok(&owner, &["branch", "protect", &url, "trunk"]);
    let open =
        |base: &str, head: &str| ok(&owner, &["pull", "create", &url, base, &url, head, head]);
    let (low, mid, top) = (open("trunk", "low"), open("low", "mid"), open("mid", "top"));
    listed(&owner, &url, &top);
    let merged = ok(&owner, &["pull", "merge", &url, &low]);
    assert!(merged.ends_with("\ndeleted low"), "{merged}");
    let diff = ok(&owner, &["pull", "diff", &url, &mid]);
    assert!(diff.contains("b/mid") && !diff.contains("b/low"), "{diff}");
    // Its page shows the stack, highest first, with what has landed.
    let (who, name) = url
        .trim_start_matches("knotless://")
        .split_once('/')
        .unwrap();
    let repo = format!("at://{who}/dev.knotless.repo/{name}");
    let stack = || -> Value {
        let at = reqwest::Url::parse_with_params(
            &format!("{}/xrpc/dev.knotless.getThread", av.base),
            [("repo", repo.as_str()), ("uri", mid.as_str())],
        )
        .unwrap();
        let thread: Value = reqwest::blocking::get(at).unwrap().json().unwrap();
        thread["stack"].clone()
    };
    wait_until(|| stack()["pulls"][2]["merged"].is_string());
    let shown = stack();
    assert_eq!(shown["into"], "trunk", "{shown}");
    let titles: Vec<&str> = (0..3)
        .map(|i| shown["pulls"][i]["item"]["title"].as_str().unwrap())
        .collect();
    assert_eq!(titles, ["top", "mid", "low"], "{shown}");
    // The base taken into its branch: the base is where it starts again.
    owner.git(&work, &["checkout", "-q", "mid"]);
    owner.git(&work, &["fetch", "-q", &url, "trunk"]);
    owner.git(&work, &["merge", "-q", "--no-edit", "FETCH_HEAD"]);
    owner.git(&work, &["push", "-q", &url, "mid"]);
    let diff = ok(&owner, &["pull", "diff", &url, &mid]);
    assert!(diff.contains("b/mid") && !diff.contains("b/low"), "{diff}");
    let merged = ok(&owner, &["pull", "merge", &url, &mid]);
    assert!(merged.ends_with("\ndeleted mid"), "{merged}");
    // Rebased, its start is no longer under it: so too.
    owner.git(&work, &["fetch", "-q", &url, "trunk"]);
    owner.git(
        &work,
        &["rebase", "-q", "--onto", "FETCH_HEAD", "mid", "top"],
    );
    owner.git(&work, &["push", "-q", "-f", &url, "top"]);
    let diff = ok(&owner, &["pull", "diff", &url, &top]);
    assert!(diff.contains("b/top") && !diff.contains("b/mid"), "{diff}");
    ok(&owner, &["pull", "merge", &url, &top]);
    owner.git(&work, &["fetch", "-q", &url, "trunk"]);
    for file in ["low", "mid", "top"] {
        let at = format!("FETCH_HEAD:{file}");
        owner.git(&work, &["cat-file", "-e", &at]);
    }

    // Someone else's commit, elsewhere on the firehose, changes nothing.
    av.app.observe(knotless_serve::firehose::Event::default());
    assert_eq!(av.app.events_for("did:plc:nobody"), 0);
    // Nor does a profile edited elsewhere on the network, one whose
    // profile is not kept: it is not kept for it either.
    av.app.observe(knotless_serve::firehose::Event {
        did: Some("did:plc:elsewhere".into()),
        ops: vec![knotless_serve::firehose::Op {
            action: "update".into(),
            collection: "app.bsky.actor.profile".into(),
            rkey: "self".into(),
            record: Some(serde_json::json!({"displayName": "Elsewhere"})),
        }],
        ..Default::default()
    });
    assert_eq!(av.app.events_for("did:plc:elsewhere"), 0);
    let kept = av.app.index.as_ref().unwrap().profile("did:plc:elsewhere");
    assert_eq!(kept.unwrap(), None);

    // A record gone before the index fetched it is an error to skip.
    let gone = knotless_serve::firehose::Op {
        action: "create".into(),
        collection: "dev.knotless.issue".into(),
        rkey: "3zzzzzzzzzzzz".into(),
        record: None,
    };
    let index = av.app.index.as_ref().unwrap();
    assert!(
        av.rt
            .block_on(knotless_serve::index_op(
                &av.app.ctx,
                index,
                &owner.account.did,
                &gone,
                None
            ))
            .is_err()
    );

    // The index knows the fork, and finds repositories by name: those
    // named as asked first, the original before its newer fork.
    let dids = |page: &Value| -> Vec<String> {
        let repos = page["repos"].as_array().unwrap();
        repos
            .iter()
            .map(|r| r["did"].as_str().unwrap().to_string())
            .collect()
    };
    let (theirs, mine) = (owner.account.did.as_str(), contrib.account.did.as_str());
    let list = |cursor: Option<&str>, limit: &str, token: Option<&str>| -> Value {
        let mut query = vec![("q", "forkable"), ("limit", limit)];
        query.extend(cursor.map(|c| ("cursor", c)));
        let mut req = reqwest::blocking::Client::new()
            .get(format!("{}/xrpc/dev.knotless.listRepos", av.base))
            .query(&query);
        if let Some(token) = token {
            req = req.bearer_auth(token);
        }
        req.send().unwrap().json().unwrap()
    };
    let found = list(None, "50", None);
    assert_eq!(dids(&found), [theirs, mine]);
    assert!(found.get("cursor").is_none());
    // A page at a time, more named so than a page holds: the first still
    // starts with the original, and the rest come newest first, in full.
    let first = list(None, "1", None);
    assert_eq!(dids(&first), [theirs, mine]);
    let second = list(first["cursor"].as_str(), "1", None);
    assert_eq!(dids(&second), [theirs]);
    assert!(dids(&list(second["cursor"].as_str(), "1", None)).is_empty());
    // Asked as an account, its own come first: the fork, to its owner.
    let token = |who: &Sandbox| {
        av.rt.block_on(async {
            let ctx = ctx(who);
            let me = knotless::session::me(&ctx).await.unwrap();
            let lxm = "dev.knotless.listRepos";
            knotless::auth::service_token(&ctx, &me, &av.did, lxm)
                .await
                .unwrap()
        })
    };
    let forker = token(&contrib);
    assert_eq!(dids(&list(None, "50", Some(&forker))), [mine, theirs]);
    // And all of its own fit on the first page, no page after lists them.
    let original = token(&owner);
    let first = list(None, "1", Some(&original));
    assert_eq!(dids(&first), [theirs, mine]);
    let next = list(first["cursor"].as_str(), "1", Some(&original));
    assert!(dids(&next).is_empty());
    // And finds their owners by handle, ignoring case, with how many each owns.
    let owners = |query: &[(&str, &str)]| -> Value {
        reqwest::blocking::Client::new()
            .get(format!("{}/xrpc/dev.knotless.searchOwners", av.base))
            .query(query)
            .send()
            .unwrap()
            .json()
            .unwrap()
    };
    let shouted = owner.account.handle.to_uppercase();
    let found = owners(&[("q", &shouted)]);
    // Another's handle may hold this one, but only this one starts with it.
    assert_eq!(
        found["owners"][0]["owner"]["did"],
        owner.account.did.as_str()
    );
    assert_eq!(found["owners"][0]["repoCount"], 1);
    // Every handle holds nothing, a page of them at most; none holds this.
    let all = owners(&[("q", ""), ("limit", "1")]);
    assert_eq!(all["owners"].as_array().unwrap().len(), 1);
    let none = owners(&[("q", "no such handle")]);
    assert!(none["owners"].as_array().unwrap().is_empty());

    // Handles are kept in the index. One that changes is looked up again
    // on the identity event saying so, and found as it is now.
    let indexed = av.app.index.as_ref().unwrap();
    let did = owner.account.did.as_str();
    let now = indexed.handle(did).unwrap().unwrap().1;
    indexed
        .set_handle(did, Some("before.example"), now)
        .unwrap();
    let before = owners(&[("q", "before.example")]);
    assert_eq!(before["owners"][0]["owner"]["handle"], "before.example");
    let kept = || indexed.handle(did).unwrap().unwrap().0;
    let renamed = |want: &str| wait_until(|| kept().as_deref() == Some(want));
    let identity = knotless_serve::firehose::Event {
        did: Some(did.into()),
        identity: true,
        ..Default::default()
    };
    // Inside the runtime, which the lookup is spawned on; out of it again
    // for the blocking requests.
    av.rt.block_on(async { av.app.observe(identity) });
    renamed(&owner.account.handle);
    // One kept a day is shown as it was, and looked up behind the page.
    indexed.set_handle(did, Some("stale.example"), 0).unwrap();
    let stale = owners(&[("q", "stale.example")]);
    assert_eq!(stale["owners"][0]["owner"]["handle"], "stale.example");
    renamed(&owner.account.handle);
    // Having none is kept ten minutes, for a handle whose DNS has just gone
    // live: within them the DID is shown, and after them it is looked up.
    let profile = || -> serde_json::Value {
        reqwest::blocking::Client::new()
            .get(format!("{}/xrpc/dev.knotless.getProfile", av.base))
            .query(&[("actor", did)])
            .send()
            .unwrap()
            .json()
            .unwrap()
    };
    let seconds = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
    let seconds = seconds.unwrap().as_secs() as i64;
    indexed.set_handle(did, None, seconds).unwrap();
    assert_eq!(profile()["handle"], did);
    assert_eq!(kept(), None);
    indexed.set_handle(did, None, seconds - 601).unwrap();
    assert_eq!(profile()["handle"], did);
    renamed(&owner.account.handle);
    let uri = format!("at://{}/dev.knotless.repo/forkable", owner.account.did);
    let indexed = av.app.index.as_ref().unwrap();
    let key = indexed.repo(&uri).unwrap().unwrap()["key"]
        .as_str()
        .unwrap()
        .to_string();
    let forks = indexed.forks(&owner.account.did, &key);
    assert_eq!(forks.unwrap().len(), 1);

    // A write its event does not carry, as a commit too big to, is read
    // from its PDS.
    let (did, (_, _, rkey)) = (
        &owner.account.did,
        knotless::forge::parse_uri(&pull).unwrap(),
    );
    let has = || {
        indexed
            .forge(did, &key)
            .unwrap()
            .iter()
            .any(|e| e.uri == pull)
    };
    indexed
        .delete(&contrib.account.did, "dev.knotless.pull", rkey)
        .unwrap();
    assert!(!has());
    let entered = av.rt.enter();
    av.app.observe(knotless_serve::firehose::Event {
        did: Some(contrib.account.did.clone()),
        ops: vec![knotless_serve::firehose::Op {
            action: "create".into(),
            collection: "dev.knotless.pull".into(),
            rkey: rkey.into(),
            record: None,
        }],
        ..Default::default()
    });
    drop(entered);
    wait_until(has);

    // A second appview, backfilled, sees the same. The PDS lists its
    // accounts all at once: those of every test binary sharing it, read
    // one by one, so it lists only these two here.
    let s = Sandbox::new();
    let index = Index::open(&s.path("i.db")).unwrap();
    let pds = listing(&av, &[&owner.account.did, &contrib.account.did]);
    let none = knotless_serve::Sources::default();
    let n = av
        .rt
        .block_on(knotless_serve::backfill(&ctx(&s), &index, &pds, &none))
        .unwrap();
    assert!(n.records >= 10 && n.read == 2, "{n:?}");
    let report = av.rt.block_on(knotless_serve::backfilled(
        &ctx(&s),
        &index,
        "http://127.0.0.1:9",
        &none,
    ));
    assert!(
        report.starts_with("backfilling from http://127.0.0.1:9: "),
        "{report}"
    );
    // Read again with nothing new, each account is passed over.
    let report = av
        .rt
        .block_on(knotless_serve::backfilled(&ctx(&s), &index, &pds, &none));
    assert!(report.ends_with(", 2 unchanged, 0 failed"), "{report}");
    let did = owner.account.did.as_str();
    let backfilled = index.forge(did, &key).unwrap().len();
    wait_until(|| indexed.forge(did, &key).unwrap().len() == backfilled);

    // A relay names only the accounts holding knotless records, a page at
    // a time, and those are what is read.
    let holder = owner.account.did.clone();
    let relay = av.rt.block_on(async move {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let url = format!("http://{}", listener.local_addr().unwrap());
        let by = axum::routing::get(
            move |q: axum::extract::Query<std::collections::HashMap<String, String>>| {
                let page = match q.get("cursor") {
                    None => serde_json::json!({
                        "repos": [{"did": holder}, {"did": "did:plc:nobodyhereatall"}],
                        "cursor": "2"
                    }),
                    Some(_) => serde_json::json!({"repos": []}),
                };
                async move { axum::Json(page) }
            },
        );
        let relay = axum::Router::new().route("/xrpc/com.atproto.sync.listReposByCollection", by);
        tokio::spawn(async move { axum::serve(listener, relay).await.unwrap() });
        url
    });
    let fresh = Index::open(&s.path("relay.db")).unwrap();
    assert!(fresh.rebuilt);
    let filled = av
        .rt
        .block_on(knotless_serve::backfill(&ctx(&s), &fresh, &relay, &none))
        .unwrap();
    // One it cannot read is tried again later, and does not stop the rest.
    assert_eq!((filled.read, filled.failed), (1, 1), "{filled:?}");
    let nobody = fresh.backfilled("did:plc:nobodyhereatall").unwrap();
    assert_eq!((nobody.rev, nobody.tries), (None, 1));
    let again = av
        .rt
        .block_on(knotless_serve::backfill(&ctx(&s), &fresh, &relay, &none))
        .unwrap();
    assert_eq!((again.unchanged, again.failed), (2, 0), "{again:?}");
    // Only the owner's: what others wrote, the relay here does not name.
    assert!(fresh.repo(&uri).unwrap().is_some());
    let owners = fresh.forge(did, &key).unwrap().len();
    assert!(owners > 0 && owners < backfilled, "{owners}");
    // Opened again, it is the same version, and kept.
    assert!(!Index::open(&s.path("relay.db")).unwrap().rebuilt);
    // A directory that answers is asked before the relay, one that does
    // not passed by, and each repository read from the mirror.
    let (mirror, asked) = mirror(&av);
    let sources = knotless_serve::Sources {
        directories: vec!["http://127.0.0.1:9".into(), relay.clone()],
        mirror: Some(mirror),
    };
    let mirrored = Index::open(&s.path("mirrored.db")).unwrap();
    let filled = av
        .rt
        .block_on(knotless_serve::backfill(
            &ctx(&s),
            &mirrored,
            "http://127.0.0.1:9",
            &sources,
        ))
        .unwrap();
    assert_eq!((filled.read, filled.failed), (1, 1), "{filled:?}");
    assert_eq!(asked.load(std::sync::atomic::Ordering::Relaxed), 1);
    assert_eq!(mirrored.forge(did, &key).unwrap().len(), owners);
    // A mirror that cannot be reached, or does not hold the repository,
    // leaves each read to its PDS.
    for (i, gone) in ["http://127.0.0.1:9", relay.as_str()]
        .into_iter()
        .enumerate()
    {
        let gone = knotless_serve::Sources {
            directories: vec![relay.clone()],
            mirror: Some(gone.into()),
        };
        let unmirrored = Index::open(&s.path(&format!("unmirrored{i}.db"))).unwrap();
        let filled = av
            .rt
            .block_on(knotless_serve::backfill(
                &ctx(&s),
                &unmirrored,
                "http://127.0.0.1:9",
                &gone,
            ))
            .unwrap();
        assert_eq!(filled.read, 1, "{filled:?}");
        assert_eq!(unmirrored.forge(did, &key).unwrap().len(), owners);
    }
    // A sweep fills it again, now and then, with what a feed missed.
    let swept = std::sync::Arc::new(Index::open(&s.path("swept.db")).unwrap());
    let sweep = av.rt.spawn(knotless_serve::sweep(
        ctx(&s),
        swept.clone(),
        relay.clone(),
        none.clone(),
        std::time::Duration::from_millis(50),
    ));
    wait_until(|| swept.repo(&uri).unwrap().is_some());
    sweep.abort();

    // A deletion takes the record out of the index.
    let pull_rkey = third.rsplit('/').next().unwrap();
    let deleted = knotless_serve::firehose::Op {
        action: "delete".into(),
        collection: "dev.knotless.pull".into(),
        rkey: pull_rkey.into(),
        record: None,
    };
    av.rt
        .block_on(knotless_serve::index_op(
            &ctx(&s),
            &index,
            &contrib.account.did,
            &deleted,
            None,
        ))
        .unwrap();
    assert_eq!(index.forge(did, &key).unwrap().len(), backfilled - 1);
}

#[test]
fn merges_close_the_issues_they_name() {
    let av = appview();
    let owner = person(account(), &av);
    let author = person(account(), &av);
    let url = owner.url("closing");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let elsewhere = owner.url("elsewhere");
    ok(&owner, &["repo", "create", &elsewhere]);
    let open = ok(&author, &["issue", "create", &url, "It breaks"]);
    let shut = ok(&author, &["issue", "create", &url, "Already done"]);
    let other = ok(&author, &["issue", "create", &elsewhere, "Not here"]);
    ok(&author, &["pull", "close", &url, &shut]);
    listed(&owner, &url, &format!("issue\tclosed\t{shut}"));
    listed(&owner, &url, &open);

    // One by its page, one closed already, one on another repository.
    let (did, _, rkey) = knotless::forge::parse_uri(&open).unwrap();
    let handle = &owner.account.handle;
    let page = format!("{}/{handle}/closing/issue/{did}/{rkey}", av.base);
    let body = format!("Fixes [it]({page}), closes {shut} and resolves {other}.");
    owner.git(&work, &["checkout", "-q", "-b", "fix"]);
    owner.commit_file(&work, "a", "fixed\n");
    owner.git(&work, &["push", "-q", &url, "fix"]);
    let pull = ok(
        &owner,
        &[
            "pull", "create", &url, "main", &url, "fix", "Fix it", "-m", &body,
        ],
    );
    listed(&owner, &url, &pull);
    let data = |item: &str| -> Value {
        let at = reqwest::Url::parse_with_params(
            &format!("{}/xrpc/dev.knotless.getThread", av.base),
            [
                (
                    "repo",
                    format!("at://{handle}/dev.knotless.repo/closing").as_str(),
                ),
                ("uri", item),
            ],
        );
        reqwest::blocking::get(at.unwrap()).unwrap().json().unwrap()
    };
    let closes = &data(&pull)["closes"];
    let titles: Vec<&str> = (closes.as_array().unwrap().iter())
        .map(|i| i["title"].as_str().unwrap())
        .collect();
    assert_eq!(titles, ["It breaks", "Already done"]);
    assert_eq!(closes[1]["state"], "closed");
    // A bare URI of one of the repository's own shows as a link to it,
    // titled; one of another repository's stays as it is.
    let html = data(&pull)["html"].as_str().unwrap().to_string();
    let (_, _, shut_key) = knotless::forge::parse_uri(&shut).unwrap();
    let shut_page = format!("/{handle}/closing/issue/{did}/{shut_key}");
    assert!(
        html.contains(&format!(r#"closes <a href="{shut_page}">Already done</a>"#)),
        "{html}"
    );
    assert!(html.contains(other.trim()), "{html}");

    // A pull that names it with no keyword mentions it, and closes nothing.
    owner.git(&work, &["checkout", "-q", "-b", "see"]);
    owner.git(&work, &["push", "-q", &url, "see"]);
    let body = format!("See {}", open.trim());
    let see = ok(
        &owner,
        &[
            "pull", "create", &url, "main", &url, "see", "See it", "-m", &body,
        ],
    );
    listed(&owner, &url, &see);
    let mentioned = &data(&open)["mentionedBy"];
    assert_eq!(mentioned[0]["item"]["title"], "Fix it");
    assert_eq!(mentioned[0]["closes"], true);
    assert_eq!(mentioned[1]["item"]["uri"], see.trim());
    assert!(mentioned[1]["closes"].is_null());
    assert_eq!(mentioned.as_array().unwrap().len(), 2);
    let shown = ok(&owner, &["pull", "show", &url, &open]);
    let lines = format!(
        "\nmentioned in {}, to close it\nmentioned in {}",
        pull.trim(),
        see.trim()
    );
    assert!(shown.contains(&lines), "{shown}");
    assert!(data(&shut)["mentionedBy"].as_array().unwrap().len() == 1);

    // Merged into the default branch, it closes the open one, as the
    // merger, saying which pull did.
    let merged = ok(&owner, &["pull", "merge", &url, &pull]);
    assert!(merged.ends_with(&format!("\nclosed {open}")), "{merged}");
    listed(&owner, &url, &format!("issue\tclosed\t{open}"));
    let shown = ok(&owner, &["pull", "show", &url, &open]);
    assert!(shown.contains(&format!("\nclosed by {pull}")), "{shown}");
    assert!(!ok(&owner, &["pull", "show", &url, &shut]).contains("closed by"));
    let listing = ok(&owner, &["issue", "list", &elsewhere]);
    assert!(
        listing.contains(&format!("issue\topen\t{other}")),
        "{listing}"
    );
    let issue = data(&open);
    assert_eq!(issue["closedBy"]["title"], "Fix it");
    assert_eq!(issue["closedBy"]["uri"], pull.trim());
    assert!(data(&shut)["closedBy"].is_null());
}

#[test]
fn pushes_mark_the_pulls_they_land_merged() {
    let av = appview();
    let owner = person(account(), &av);
    let contrib = person(account(), &av);
    let url = owner.url("landed");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let start = owner.git(&work, &["rev-parse", "HEAD"]);
    let issue = ok(&contrib, &["issue", "create", &url, "It breaks"]);

    // A stack, a pull of its own, one already on main, one into a branch
    // the push leaves alone, and one from a fork.
    owner.git(&work, &["checkout", "-q", "-b", "a"]);
    let a = owner.commit_file(&work, "a", "a\n");
    owner.git(&work, &["checkout", "-q", "-b", "b"]);
    let b = owner.commit_file(&work, "b", "b\n");
    owner.git(&work, &["checkout", "-q", "-b", "c", &start]);
    owner.commit_file(&work, "c", "c\n");
    owner.git(&work, &["branch", "e", &start]);
    owner.git(&work, &["branch", "dev", &start]);
    owner.git(
        &work,
        &["push", "-q", &url, "a", "b", "c", "e", "dev", "c:d"],
    );
    let pa = ok(&owner, &["pull", "create", &url, "main", &url, "a", "A"]);
    let fixes = format!("Fixes {}", issue.trim());
    let pb = ok(
        &owner,
        &["pull", "create", &url, "main", &url, "b", "B", "-m", &fixes],
    );
    let pc = ok(&owner, &["pull", "create", &url, "main", &url, "c", "C"]);
    let pd = ok(&owner, &["pull", "create", &url, "dev", &url, "d", "D"]);
    let pe = ok(&owner, &["pull", "create", &url, "main", &url, "e", "E"]);
    let fork = ok(&contrib, &["repo", "fork", &url]);
    let clone = contrib.path("clone");
    contrib.git(contrib.dir.path(), &["clone", "-q", &fork, "clone"]);
    let f = contrib.commit_file(&clone, "f", "from the fork\n");
    contrib.git(&clone, &["push", "-q", "origin", "HEAD:f"]);
    let pf = ok(&contrib, &["pull", "create", &url, "main", &fork, "f", "F"]);
    for pull in [&pa, &pb, &pc, &pd, &pe, &pf] {
        listed(&owner, &url, pull);
    }

    // The stack and the fork's branch pushed to main by hand, b with them.
    owner.git(&work, &["checkout", "-q", "b"]);
    owner.git(
        &work,
        &["pull", "-q", "--no-rebase", "--no-edit", &fork, "f"],
    );
    let (pushed, _, said) = owner.git_raw(&work, &["push", &url, "HEAD:main", "b"]);
    assert!(pushed, "{said}");
    for pull in [&pa, &pb, &pf] {
        let line = format!("knotless: {} merged, as main now has its head", pull.trim());
        assert!(said.contains(&line), "{said}");
    }
    for pull in [&pa, &pb, &pf] {
        listed(&owner, &url, &format!("pull\tmerged\t{}", pull.trim()));
    }
    listed(&owner, &url, &format!("issue\tclosed\t{}", issue.trim()));
    for pull in [&pc, &pd, &pe] {
        listed(&owner, &url, &format!("pull\topen\t{}", pull.trim()));
    }

    // One already on its base merges as it stands, moving nothing.
    let tip = owner.git(&work, &["rev-parse", "HEAD"]);
    let merged = ok(&owner, &["pull", "merge", &url, &pe]);
    assert!(merged.starts_with(&start), "{merged}");
    let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
    assert!(refs.contains(&format!("{tip}\trefs/heads/main")), "{refs}");

    // Each commit links to the pull that brought it: the least that did.
    let handle = &owner.account.handle;
    let repo = format!("at://{handle}/dev.knotless.repo/landed");
    let at = reqwest::Url::parse_with_params(
        &format!("{}/xrpc/dev.knotless.listCommits", av.base),
        [("repo", repo.as_str())],
    );
    let pulls = || -> Vec<(String, Value)> {
        let page: Value = reqwest::blocking::get(at.clone().unwrap())
            .unwrap()
            .json()
            .unwrap();
        let commits = page["commits"].as_array().unwrap().iter();
        commits
            .map(|c| (c["sha"].as_str().unwrap().into(), c["pull"]["uri"].clone()))
            .collect()
    };
    wait_until(|| {
        pulls()
            .iter()
            .any(|(sha, pull)| *sha == f && pull == pf.trim())
    });
    let pulls = pulls();
    let of = |sha: &str| pulls.iter().find(|(s, _)| s == sha).unwrap().1.clone();
    assert_eq!(of(&a), pa.trim());
    assert_eq!(of(&b), pb.trim());
    assert_eq!(of(&start), Value::Null);
}

#[test]
fn merges_wait_for_their_checks() {
    let av = appview();
    let mut owner = person(account(), &av);
    // A merge that waits reads its checks again this often.
    owner.set("KNOTLESS_POLL_MS", "100");
    // The runner's account, and someone who is not the owner.
    let ci = person(account(), &av);
    let url = owner.url("gated");
    let work = owner.work("w", 1);
    std::fs::create_dir_all(work.join(".knotless/workflows")).unwrap();
    // One runs on pulls; the other only on pushes, which no merge waits for.
    let on_pull = "on: {pull: {}}\nsteps: [{run: 'true'}]\n";
    owner.commit_file(&work, ".knotless/workflows/ci.yml", on_pull);
    let base = owner.commit_file(&work, ".knotless/workflows/push.yml", "steps: []\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    ok(&owner, &["runner", "add", &url, &ci.account.handle]);
    let key = owner.record("dev.knotless.repo", "gated")["key"]
        .as_str()
        .unwrap()
        .to_string();
    let repo = serde_json::json!({
        "uri": format!("at://{}/dev.knotless.repo/gated", owner.account.did),
        "key": key,
    });
    let mut second = 0;
    // The runner's check of `pull` at `head` merged with `tip`, or of the
    // head alone, as before checks merged it, newer than any before.
    let mut check = |pull: &str, head: &str, tip: Option<&str>, status: &str| {
        second += 1;
        let rkey = knotless::keys::check_rkey(&owner.account.did, &key, pull, head, tip, "ci");
        let mut record = serde_json::json!({
            "$type": "dev.knotless.check", "repo": repo, "ref": "refs/heads/x",
            "pull": pull, "commit": head, "workflow": "ci", "status": status,
            "startedAt": format!("2026-10-21T00:00:{second:02}.000Z"),
        });
        if let Some(tip) = tip {
            record["baseCommit"] = tip.into();
        }
        ci.put_record("dev.knotless.check", &rkey, record);
    };
    let refused = |who: &Sandbox, args: &[&str], why: &str| {
        let (merged, _, err) = who.knotless(args, b"");
        assert!(!merged && err.contains(why), "{args:?}: {err}");
        let logs = format!("`knotless run list {url} --pull ");
        assert!(err.contains(&logs), "{err}");
    };

    // The base has not moved, so its head alone is what is checked.
    owner.git(&work, &["checkout", "-q", "-b", "x"]);
    let head = owner.commit_file(&work, "x", "x\n");
    owner.git(&work, &["push", "-q", &url, "x"]);
    let pull = ok(&owner, &["pull", "create", &url, "main", &url, "x", "X"]);
    listed(&owner, &url, &pull);
    refused(&owner, &["pull", "merge", &url, &pull], "none has run yet");
    let checked = ok(&owner, &["pull", "merge", &url, &pull, "--check"]);
    let said = "blocked\tmerge\tchecks pending\ndeletes\tx\nmay\tmerge,force,base,head,rerun\n";
    assert!(checked.starts_with(said), "{checked}");
    assert!(checked.contains("none has run yet"), "{checked}");
    check(&pull, &base, Some(&base), "success");
    refused(
        &owner,
        &["pull", "merge", &url, &pull],
        "of an older head, ",
    );
    check(&pull, &head, None, "success");
    refused(
        &owner,
        &["pull", "merge", &url, &pull],
        "of main at an older tip",
    );
    let moved = "e".repeat(40);
    check(&pull, &head, Some(&moved), "success");
    refused(
        &owner,
        &["pull", "merge", &url, &pull],
        "of main at eeeeeeeeeeee",
    );
    // Waiting for it, the owner asks the runners to run it.
    refused(
        &owner,
        &["pull", "merge", &url, &pull, "--wait", "1"],
        "no checks at its head",
    );
    let asked = owner.records("dev.knotless.rerun");
    assert!(
        asked.iter().any(|(_, r)| r["pull"] == pull.as_str()),
        "{asked:?}"
    );
    check(&pull, &head, Some(&base), "running");
    let checked = ok(&owner, &["pull", "merge", &url, &pull, "--check"]);
    assert!(
        checked.starts_with("blocked\tmerge\tchecks running\n"),
        "{checked}"
    );
    refused(
        &owner,
        &["pull", "merge", &url, &pull, "--wait", "1"],
        "still running: ci",
    );
    let (merged, _, err) = ci.knotless(&["pull", "merge", &url, &pull, "--force"], b"");
    assert!(!merged && err.contains("only the owner"), "{err}");
    fails(&owner, &["pull", "merge", &url, &pull, "--wait", "soon"]);
    // Waiting, it reads them again until they pass: asked for them, it
    // has read them once, and waits to read again however long that took.
    check(&pull, &head, Some(&base), "cancelled");
    let asks = owner.records("dev.knotless.rerun");
    let wait = ["pull", "merge", &url, &pull, "--wait", "60"];
    let tip = std::thread::scope(|scope| {
        let merging = scope.spawn(|| owner.knotless(&wait, b""));
        wait_until(|| owner.records("dev.knotless.rerun") != asks);
        check(&pull, &head, Some(&base), "success");
        let (merged, out, err) = merging.join().unwrap();
        assert!(merged, "{err}");
        let (made, deleted) = out.split_once('\n').unwrap();
        assert!(made != head && deleted == "deleted x", "{out}");
        made.to_string()
    });
    // Merged, it can no longer say whether it merges.
    let (checked, _, err) = owner.knotless(&["pull", "merge", &url, &pull, "--check"], b"");
    assert!(!checked && err.contains("is merged"), "{err}");

    // The base moved: what is checked is the merge of the two.
    // A workflow that does not load is waited for too, as its runner files
    // an error for it; what failed is said before what has not run.
    owner.git(&work, &["checkout", "-q", "-b", "y", &base]);
    owner.commit_file(&work, ".knotless/workflows/bad.yml", "steps: 3\n");
    let y = owner.commit_file(&work, "y", "y\n");
    owner.git(&work, &["push", "-q", &url, "y"]);
    let pull = ok(&owner, &["pull", "create", &url, "main", &url, "y", "Y"]);
    listed(&owner, &url, &pull);
    check(&pull, &y, Some(&tip), "failure");
    let checked = ok(&owner, &["pull", "merge", &url, &pull, "--check"]);
    assert!(
        checked.starts_with("blocked\tmerge\tchecks failed\n"),
        "{checked}"
    );
    // The workflows at main, and what runs each there: one that does not
    // load is at the pull's branch only.
    let listed = ok(&owner, &["workflow", "list", &url]);
    assert_eq!(listed, "refs/heads/main\nci\tpull\npush\tpush,asked");
    let listed = ok(&owner, &["workflow", "list", &url, "y"]);
    assert!(listed.starts_with("refs/heads/y\nbad\t\t"), "{listed}");
    fails(&owner, &["workflow", "list", &url, "nope"]);
    refused(
        &owner,
        &["pull", "merge", &url, &pull],
        "did not pass its checks at its head, ",
    );
    let (_, _, err) = owner.knotless(&["pull", "merge", &url, &pull], b"");
    assert!(
        err.contains(": ci (failure). Read their logs with"),
        "{err}"
    );
    let (merged, out, err) = owner.knotless(&["pull", "merge", &url, &pull, "--force"], b"");
    assert!(merged, "{err}");
    assert_ne!(out.trim(), y);
}

#[test]
fn a_check_left_running_is_read_again() {
    let av = appview();
    let owner = person(account(), &av);
    let url = owner.url("rechecked");
    let work = owner.work("w", 1);
    owner.commit_file(&work, "a", "a\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    ok(&owner, &["runner", "add", &url, &owner.account.handle]);
    let key = owner.record("dev.knotless.repo", "rechecked")["key"]
        .as_str()
        .unwrap()
        .to_string();
    let uri = format!("at://{}/dev.knotless.repo/rechecked", owner.account.did);
    let record = |status: &str, at: &str| {
        serde_json::json!({
            "$type": "dev.knotless.check", "repo": {"uri": uri, "key": key},
            "ref": "refs/heads/main", "commit": "0".repeat(40), "workflow": "deploy",
            "status": status, "startedAt": at,
        })
    };
    let listed = format!(
        "{}/xrpc/dev.knotless.listChecks?repo={uri}&key={key}",
        av.base
    );
    let status = |rkey: &str| -> String {
        let page: Value = reqwest::blocking::get(&listed).unwrap().json().unwrap();
        let found = page["checks"]
            .as_array()
            .unwrap()
            .iter()
            .find(|c| c["uri"].as_str().unwrap().ends_with(&format!("/{rkey}")));
        found.map_or(String::new(), |c| c["value"]["status"].to_string())
    };
    // One finished long since, and one started just now.
    let long_ago = "2026-10-21T00:00:00.000Z";
    owner.put_record("dev.knotless.check", "done", record("success", long_ago));
    let now = knotless::records::now();
    owner.put_record("dev.knotless.check", "now", record("running", &now));
    wait_until(|| status("done") == "\"success\"" && status("now") == "\"running\"");

    // The index missed the write that finished it, as one written while
    // serve rolls over: read from its PDS again, a page later it is done.
    let index = av.app.index.as_ref().unwrap();
    let missed = record("running", long_ago);
    index
        .put(&owner.account.did, "dev.knotless.check", "done", &missed)
        .unwrap();
    assert_eq!(status("done"), "\"running\"");
    wait_until(|| status("done") == "\"success\"");
    assert_eq!(status("now"), "\"running\"");
}

#[test]
fn pulls_squash_into_a_linear_branch() {
    let av = appview();
    let owner = person(account(), &av);
    let contrib = person(account(), &av);
    let url = owner.url("squashed");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let base = owner.git(&work, &["rev-parse", "HEAD"]);
    let fork = ok(&contrib, &["repo", "fork", &url]);
    let clone = contrib.path("clone");
    contrib.git(contrib.dir.path(), &["clone", "-q", &fork, "clone"]);
    contrib.git(&clone, &["checkout", "-q", "-b", "two"]);
    contrib.commit_file(&clone, "a", "one\n");
    contrib.commit_file(&clone, "b", "two\n");
    contrib.git(&clone, &["push", "-q", "origin", "two"]);
    let pull = ok(
        &contrib,
        &[
            "pull",
            "create",
            &url,
            "main",
            &fork,
            "two",
            "Add a and b",
            "-m",
            "both",
        ],
    );
    listed(&owner, &url, &pull);

    // A fast-forward would bring both commits; protected, it is one.
    ok(&owner, &["branch", "protect", &url, "main"]);
    let checked = ok(&owner, &["pull", "merge", &url, &pull, "--check"]);
    assert_eq!(checked, "clean\tsquash\tchecks none\nmay\tmerge,force,base");
    fails(
        &owner,
        &["pull", "merge", &url, &pull, "--check", "--queue"],
    );
    // Merged by the knotless binary, which commits as git does on the
    // owner's machine.
    let (merged, sha, err) = owner.knotless(&["pull", "merge", &url, &pull], b"");
    assert!(merged, "{err}");
    let check = owner.path("check");
    owner.git(owner.dir.path(), &["clone", "-q", &url, "check"]);
    assert_eq!(owner.git(&check, &["rev-parse", "HEAD"]), sha);
    assert_eq!(owner.git(&check, &["rev-parse", "HEAD^"]), base);
    assert_eq!(owner.git(&check, &["rev-list", "--count", "HEAD"]), "2");
    assert!(check.join("a").exists() && check.join("b").exists());
    let message = owner.git(&check, &["log", "-1", "--format=%an|%cn <%ce>|%B"]);
    // The head's author wrote it, and the owner, who merged it, committed it.
    assert_eq!(
        message,
        format!("Test|Test <t@example.com>|Add a and b\n\nboth\n\n{pull}")
    );
}

/// A merged pull reverts as GitHub's Revert button does: one commit that
/// undoes what its merge brought in, on a branch cut from the base, and a
/// pull of it, by the owner or a collaborator; whether it fast-forwarded,
/// merged or squashed. Not one still open, nor one whose undoing conflicts.
#[test]
fn merged_pulls_revert_in_pulls_of_their_own() {
    let av = appview();
    let owner = person(account(), &av);
    let contrib = person(account(), &av);
    let stranger = person(account(), &av);
    let url = owner.url("reverted");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let refused = |who: &Sandbox, args: &[&str], why: &str| {
        let (done, _, err) = who.knotless(args, b"");
        assert!(!done && err.contains(why), "{args:?}: {err}");
    };
    // A branch of the repository from main as it is now, with `files`.
    let branch = |name: &str, files: &[&str]| {
        owner.git(&work, &["fetch", "-q", &url, "main"]);
        owner.git(&work, &["checkout", "-q", "-B", name, "FETCH_HEAD"]);
        for f in files {
            owner.commit_file(&work, f, &format!("{f}\n"));
        }
        owner.git(&work, &["push", "-q", "-f", &url, name]);
    };
    let pull = |into: &str, name: &str, title: &str| {
        let pull = ok(&owner, &["pull", "create", &url, into, &url, name, title]);
        listed(&owner, &url, &pull);
        pull
    };
    // The files at the tip of `branch`.
    let files = |branch: &str| {
        owner.git(&work, &["fetch", "-q", &url, branch]);
        owner.git(&work, &["ls-tree", "--name-only", "FETCH_HEAD"])
    };
    let revert_of = |pull: &str| format!("revert/{}", pull.rsplit('/').next().unwrap());

    // A merge commit on a base that had not moved: not until it merges,
    // and not by just anyone.
    branch("b", &["b"]);
    let add_b = pull("main", "b", "Add b");
    let open = "is open: only a merged pull reverts";
    refused(&owner, &["pull", "revert", &url, &add_b], open);
    let merged_b = ok(&owner, &["pull", "merge", &url, &add_b]);
    refused(
        &stranger,
        &["pull", "revert", &url, &add_b],
        "only the owner",
    );
    fails(&owner, &["pull", "revert", &url, &add_b, "--bogus"]);
    let why = ["pull", "revert", &url, &add_b, "-m", "It broke the build"];
    let reverting = ok(&owner, &why);
    let mine = format!("at://{}/dev.knotless.pull/", owner.account.did);
    assert!(reverting.starts_with(&mine), "{reverting}");
    let undo_b = revert_of(&add_b);
    assert_eq!(files(&undo_b), "f");
    let said = owner.git(&work, &["log", "-1", "--format=%s|%P", "FETCH_HEAD"]);
    let tip = merged_b.lines().next().unwrap();
    assert_eq!(said, format!("Revert \"Add b\"|{tip}"));
    refused(
        &owner,
        &["pull", "revert", &url, &add_b],
        "has a branch revert/",
    );
    // Its pull, into main, says what it reverts and why.
    listed(&owner, &url, &format!("{reverting}\tRevert \"Add b\""));
    let shown = ok(&owner, &["pull", "show", &url, &reverting]);
    assert!(
        shown.contains("Reverts [Add b](") && shown.contains(&add_b),
        "{shown}"
    );
    assert!(shown.contains("It broke the build"), "{shown}");
    assert!(shown.contains(&format!("reverts {add_b}")), "{shown}");
    // Not undone until the revert merges.
    let before = ok(&owner, &["pull", "show", &url, &add_b]);
    assert!(!before.contains("reverted by"), "{before}");

    // A merge commit, reverted by a collaborator through the gateway: what
    // main took since stays.
    ok(
        &owner,
        &["account", "delegate", &owner.account.handle, &av.did],
    );
    ok(
        &owner,
        &["repo", "share", &url, &contrib.account.handle, "--write"],
    );
    branch("c", &["c"]);
    branch("d", &["d"]);
    owner.git(&work, &["push", "-q", &url, "d:main"]);
    let add_c = pull("main", "c", "Add c");
    ok(&owner, &["pull", "merge", &url, &add_c]);
    ok(&contrib, &["pull", "revert", &url, &add_c]);
    assert_eq!(files(&revert_of(&add_c)), "b\nd\nf");

    // Once a revert lands, there is nothing left to revert.
    ok(&owner, &["pull", "merge", &url, &reverting]);
    refused(&owner, &["pull", "revert", &url, &add_b], "holds nothing");
    // The pull it undid names it.
    listed(&owner, &url, &format!("pull\tmerged\t{reverting}"));
    let undone = ok(&owner, &["pull", "show", &url, &add_b]);
    assert!(
        undone.contains(&format!("reverted by {reverting}")),
        "{undone}"
    );

    // A squash, of two commits, into a linear branch.
    ok(&owner, &["branch", "protect", &url, "main"]);
    branch("ef", &["e", "f2"]);
    let add_ef = pull("main", "ef", "Add e and f2");
    ok(&owner, &["pull", "merge", &url, &add_ef]);
    assert_eq!(files("main"), "c\nd\ne\nf\nf2");
    ok(&owner, &["pull", "revert", &url, &add_ef]);
    assert_eq!(files(&revert_of(&add_ef)), "c\nd\nf");

    // Changed since on main, it conflicts, and nothing moves.
    branch("g", &["g"]);
    let add_g = pull("main", "g", "Add g");
    ok(&owner, &["pull", "merge", &url, &add_g]);
    branch("g2", &[]);
    owner.commit_file(&work, "g", "changed\n");
    owner.git(&work, &["push", "-q", &url, "g2:main"]);
    refused(
        &owner,
        &["pull", "revert", &url, &add_g],
        "reverting conflicts in g",
    );
    assert!(!ok(&owner, &["repo", "refs", &url]).contains(&revert_of(&add_g)));

    // A branch that no longer holds the merge has nothing to revert.
    owner.git(&work, &["push", "-q", &url, "main:side"]);
    branch("h", &["h"]);
    let into_side = pull("side", "h", "Add h");
    ok(&owner, &["pull", "merge", &url, &into_side]);
    ok(&owner, &["branch", "delete", &url, "side"]);
    refused(
        &owner,
        &["pull", "revert", &url, &into_side],
        "side no longer holds",
    );
}

/// A pull's branch brought up to date with its base, as GitHub's Update
/// branch button does: the base merged into it and pushed there, by the
/// owner or a collaborator. Lines both sides only added at the same place
/// are kept, the base's first; any other conflict leaves it as it was.
#[test]
fn pull_branches_update_from_their_base() {
    let av = appview();
    let owner = person(account(), &av);
    let stranger = person(account(), &av);
    let url = owner.url("updated");
    let work = owner.work("w", 1);
    owner.commit_file(&work, "notes", "one\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    let refused = |who: &Sandbox, why: &str, pull: &str| {
        let (done, _, err) = who.knotless(&["pull", "update-branch", &url, pull], b"");
        assert!(!done && err.contains(why), "{err}");
    };
    // `notes` as `branch` holds it, pushed there.
    let write = |branch: &str, notes: &str| {
        owner.git(&work, &["fetch", "-q", &url, branch]);
        owner.git(&work, &["checkout", "-q", "-B", branch, "FETCH_HEAD"]);
        owner.commit_file(&work, "notes", notes);
        owner.git(&work, &["push", "-q", &url, branch]);
    };
    owner.git(&work, &["push", "-q", &url, "main:notes"]);
    write("notes", "one\nmine\n");
    let pull = ok(
        &owner,
        &["pull", "create", &url, "main", &url, "notes", "Notes"],
    );
    listed(&owner, &url, &pull);

    // Holding its base already, it is left as it is.
    let said = ok(&owner, &["pull", "update-branch", &url, &pull]);
    assert_eq!(said, "notes holds its base already");

    // Main added a line where the pull did: both stay, main's first.
    write("main", "one\nmain\n");
    refused(&stranger, "only the owner", &pull);
    let said = ok(&owner, &["pull", "update-branch", &url, &pull]);
    let merge = said.lines().next().unwrap();
    let merge = merge.strip_prefix("merged its base into notes: ").unwrap();
    assert!(
        said.ends_with("both sides only added lines in notes: the base's come first"),
        "{said}"
    );
    owner.git(&work, &["fetch", "-q", &url, "notes"]);
    assert_eq!(owner.git(&work, &["rev-parse", "FETCH_HEAD"]), merge);
    assert_eq!(
        owner.git(&work, &["show", "FETCH_HEAD:notes"]),
        "one\nmain\nmine"
    );
    let subject = owner.git(&work, &["log", "-1", "--format=%s", "FETCH_HEAD"]);
    assert_eq!(subject, "Merge main into notes");
    let parents = owner.git(&work, &["log", "-1", "--format=%P", "FETCH_HEAD"]);
    assert_eq!(parents.split(' ').count(), 2, "{parents}");

    // Both changing the same line, it is the author's to merge.
    write("main", "uno\nmain\n");
    write("notes", "one!\nmain\nmine\n");
    refused(&owner, "same lines in notes: merge by hand", &pull);
    owner.git(&work, &["fetch", "-q", &url, "notes"]);
    assert_eq!(
        owner.git(&work, &["show", "FETCH_HEAD:notes"]),
        "one!\nmain\nmine"
    );
}

#[test]
fn issues_and_who_may_close_them() {
    let av = appview();
    let mut owner = person(account(), &av);
    let author = person(account(), &av);
    let stranger = person(account(), &av);
    let url = owner.url("tracked");
    ok(&owner, &["repo", "create", &url]);
    let issue = ok(
        &author,
        &["issue", "create", &url, "It breaks", "-m", "when I do this"],
    );
    let quiet = ok(&author, &["issue", "create", &url, "Also this"]);
    listed(&owner, &url, &issue);
    listed(&owner, &url, &quiet);
    assert_eq!(ok(&author, &["issue", "close", &url, &issue]), "closed");
    listed(&owner, &url, &format!("issue\tclosed\t{issue}"));
    // Nothing is written that readers would only ignore: a stranger's
    // reopening, closing what is closed already, or an issue that is not
    // there, as one named under the wrong account.
    let refused = |who: &Sandbox, args: &[&str], why: &str| {
        let (done, _, err) = who.knotless(args, b"");
        assert!(!done && err.contains(why), "{args:?}: {err}");
    };
    refused(
        &stranger,
        &["issue", "reopen", &url, &issue],
        "may reopen it",
    );
    refused(
        &author,
        &["issue", "close", &url, &issue],
        "is closed already",
    );
    let elsewhere = issue.replace(&author.account.did, &stranger.account.did);
    refused(
        &stranger,
        &["issue", "close", &url, &elsewhere],
        "no pull or issue",
    );
    assert!(stranger.records("dev.knotless.state").is_empty());
    assert_eq!(author.records("dev.knotless.state").len(), 1);
    // The owner's does.
    ok(&owner, &["issue", "reopen", &url, &issue]);
    listed(&owner, &url, &format!("issue\topen\t{issue}"));
    assert_eq!(ok(&owner, &["pull", "list", &url]), "");
    assert!(ok(&owner, &["pull", "show", &url, &quiet]).ends_with(": Also this"));
    // One just written, which the appview's index does not have yet, is
    // read from its author's repo.
    let behind = appview_following(false, "");
    let fresh = ok(&author, &["issue", "create", &url, "Just now"]);
    owner.set("KNOTLESS_APPVIEW", &behind.base);
    assert!(ok(&owner, &["pull", "show", &url, &fresh]).ends_with(": Just now"));
    owner.set("KNOTLESS_APPVIEW", &av.base);
    fails(&owner, &["issue", "list", &url, "--bogus"]);
    fails(&owner, &["pull", "merge", &url, &issue]);
    fails(
        &owner,
        &[
            "issue",
            "show",
            &url,
            "at://did:plc:nobody/dev.knotless.issue/x",
        ],
    );
    // A comment is about a pull or an issue on this repository.
    fails(
        &owner,
        &[
            "comment",
            "add",
            &url,
            "at://did:plc:x/dev.knotless.state/3abc",
            "?",
        ],
    );
    fails(
        &owner,
        &[
            "comment",
            "add",
            &url,
            &format!("{}x", issue.trim_end_matches(|c: char| c.is_alphanumeric())),
            "?",
        ],
    );
    ok(&owner, &["repo", "create", &owner.url("elsewhere")]);
    fails(
        &owner,
        &["comment", "add", &owner.url("elsewhere"), &issue, "?"],
    );
    fails(&owner, &["issue", "create", &url, "t", "-x"]);
    fails(&owner, &["issue", "create", &url, "t", "-m"]);
    fails(&owner, &["repo", "fork", &owner.url("nothing")]);
    // One's own is not forked: a fork is of someone else's.
    let parsed = knotless::url::RepoUrl::parse(&url).unwrap();
    let rt = tokio::runtime::Runtime::new().unwrap();
    let owned = ctx(&owner);
    let own = knotless::forge::fork(&owned, &parsed, Some("tracked-copy"));
    let why = format!("{:#}", rt.block_on(own).unwrap_err());
    assert!(
        why.ends_with("a fork is of someone else's repository"),
        "{why}"
    );

    // Its author changes what an issue or a comment says, and takes a
    // comment back; nobody else can, and the web pages only read.
    fails(
        &owner,
        &["issue", "edit", &url, &issue, "--title", "Hijacked"],
    );
    fails(&author, &["issue", "edit", &url, &issue]);
    fails(&author, &["issue", "edit", &url, &issue, "--sideways", "x"]);
    let state = format!("at://{}/dev.knotless.state/3abc", author.account.did);
    fails(&author, &["pull", "edit", &url, &state, "-m", "x"]);
    // What another client wrote into it, that knotless does not know, is
    // kept through an edit.
    let rkey = issue.rsplit('/').next().unwrap();
    let mut held = author.record("dev.knotless.issue", rkey);
    held["dev.example.labels"] = serde_json::json!(["bug"]);
    author.put_record("dev.knotless.issue", rkey, held.clone());
    assert_eq!(
        ok(
            &author,
            &[
                "issue",
                "edit",
                &url,
                &issue,
                "--title",
                "It breaks badly",
                "-m",
                "every time"
            ]
        ),
        "edited"
    );
    wait_until(|| {
        let shown = ok(&owner, &["issue", "show", &url, &issue]);
        shown.contains("It breaks badly") && shown.contains("every time")
    });
    let edited = author.record("dev.knotless.issue", rkey);
    assert_eq!(edited["dev.example.labels"], held["dev.example.labels"]);
    assert_eq!(edited["createdAt"], held["createdAt"]);
    assert!(edited["updatedAt"].as_str().unwrap() > held["createdAt"].as_str().unwrap());
    // The appview's index has it as edited, for the listing to show.
    wait_until(|| ok(&owner, &["issue", "list", &url]).contains("It breaks badly"));
    ok(&author, &["issue", "edit", &url, &issue, "-m", ""]);
    wait_until(|| !ok(&owner, &["issue", "show", &url, &issue]).contains("every time"));
    let said = ok(&author, &["comment", "add", &url, &issue, "first try"]);
    fails(&author, &["pull", "edit", &url, &said, "--title", "x"]);
    fails(&author, &["pull", "edit", &url, &said, "-m", ""]);
    ok(&author, &["pull", "edit", &url, &said, "-m", "second try"]);
    wait_until(|| ok(&owner, &["issue", "show", &url, &issue]).contains("second try"));
    fails(&owner, &["comment", "delete", &url, &said]);
    fails(&author, &["comment", "delete", &url, &issue]);
    assert_eq!(ok(&author, &["comment", "delete", &url, &said]), "deleted");
    wait_until(|| !ok(&owner, &["issue", "show", &url, &issue]).contains("second try"));
    // One gone, or about another repository, is not there to change.
    fails(&author, &["comment", "delete", &url, &said]);
    fails(
        &author,
        &[
            "issue",
            "edit",
            &owner.url("elsewhere"),
            &issue,
            "--title",
            "x",
        ],
    );

    // Without an appview there is nowhere to find them.
    let mut lost = person(owner.account.clone(), &av);
    lost.set("KNOTLESS_APPVIEW", "");
    let (code, out) = lost.cli(&["issue", "list", &url], b"");
    assert_eq!(code, 1, "{out}");

    // A server without an index says so.
    let s = Sandbox::new();
    let rt = tokio::runtime::Runtime::new().unwrap();
    let listener = rt
        .block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
        .unwrap();
    let base = format!("http://{}", listener.local_addr().unwrap());
    let bare = App::new(ctx(&s), Duration::ZERO);
    let router = router(bare.clone());
    rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
    for path in [
        "dev.knotless.getForge?repo=at://did:plc:a/dev.knotless.repo/r&key=k",
        "dev.knotless.listRepos",
        "dev.knotless.listChecks?repo=at://did:plc:a/dev.knotless.repo/r&key=k",
        "com.atproto.label.queryLabels?uriPatterns=*",
    ] {
        let res = reqwest::blocking::get(format!("{base}/xrpc/{path}")).unwrap();
        assert_eq!(res.status().as_u16(), 404);
    }
    // Nor can it follow a labeler, and an account is not one: each try
    // fails, and it tries again.
    for (app, did) in [
        (&bare, av.did.clone()),
        (&av.app, owner.account.did.clone()),
    ] {
        let following = knotless_serve::moderation::follow(app.clone(), did);
        let _ =
            rt.block_on(async { tokio::time::timeout(Duration::from_secs(1), following).await });
    }
    // A repository is named by its record's URI.
    let res = reqwest::blocking::get(format!(
        "{}/xrpc/dev.knotless.getForge?repo=x&key=k",
        av.base
    ))
    .unwrap();
    assert_eq!(res.status().as_u16(), 400);

    // Every record comes back, a page at a time.
    let key = owner.record("dev.knotless.repo", "tracked")["key"].clone();
    let repo = format!("at://{}/dev.knotless.repo/tracked", owner.account.did);
    let forge = |cursor: &str| -> Value {
        reqwest::blocking::get(format!(
            "{}/xrpc/dev.knotless.getForge?repo={repo}&key={}&limit=1&cursor={cursor}",
            av.base,
            key.as_str().unwrap()
        ))
        .unwrap()
        .json()
        .unwrap()
    };
    let (mut paged, mut cursor) = (0, String::new());
    loop {
        let page = forge(&cursor);
        assert!(page["entries"].as_array().unwrap().len() <= 1);
        paged += page["entries"].as_array().unwrap().len();
        match page["cursor"].as_str() {
            Some(next) => cursor = next.to_string(),
            None => break,
        }
    }
    assert!(paged > 1, "{paged}");
}

#[test]
fn labels_and_who_may_put_them_on() {
    let av = appview();
    let owner = person(account(), &av);
    let collab = person(account(), &av);
    let author = person(account(), &av);
    let url = owner.url("labelled");
    ok(&owner, &["repo", "create", &url]);
    // The owner defines them, and only the owner.
    let bug = ["--color", "#d73a4a", "--description", "Something broken"];
    ok(
        &owner,
        &[&["label", "define", &url, "kind/bug"][..], &bug].concat(),
    );
    ok(
        &owner,
        &["label", "define", &url, "priority/high", "--exclusive"],
    );
    ok(
        &owner,
        &["label", "define", &url, "priority/low", "--exclusive"],
    );
    let defined = ok(&owner, &["label", "list", &url]);
    assert!(
        defined.starts_with("kind/bug\t#d73a4a\t\tSomething broken\npriority/high\t\texclusive"),
        "{defined}"
    );
    // Defining one again replaces it in its place.
    let low = [
        "labels",
        &url,
        "add",
        "priority/low",
        "--exclusive",
        "--color",
        "#0e8a16",
    ];
    let redefined = ok(&owner, &low);
    assert_eq!(redefined.lines().count(), 3, "{redefined}");
    assert!(
        redefined.ends_with("priority/low\t#0e8a16\texclusive"),
        "{redefined}"
    );
    for bad in [
        &["label", "define", &url, "x", "--color", "red"][..],
        &["label", "define", &url, ""],
        &["label", "define", &url, "x", "--bogus"],
        &["label", "delete", &url, "nothing"],
        &["label", "bogus", &url],
        &["label", "list", &owner.url("nothing")],
        &["label", "define", &owner.url("nothing"), "x"],
    ] {
        fails(&owner, bad);
    }
    fails(&author, &["label", "define", &url, "mine"]);

    // Only the owner and collaborators put them on, and only those defined.
    let issue = ok(&author, &["issue", "create", &url, "It breaks"]);
    fails(&author, &["issue", "label", &url, &issue, "kind/bug"]);
    fails(&owner, &["issue", "label", &url, &issue, "kind/feature"]);
    fails(&owner, &["issue", "label", &url, &issue]);
    // What one says, the others see once the index has it.
    let shows = |labels: &str| {
        wait_until(|| {
            let shown = author.cli(&["issue", "show", &url, &issue], b"").1;
            shown.lines().nth(1) == Some(labels)
        })
    };
    let set = ok(
        &owner,
        &["issue", "label", &url, &issue, "kind/bug", "priority/high"],
    );
    assert_eq!(set, "kind/bug\npriority/high");
    shows("labels: kind/bug, priority/high");
    // The pages show each as the repository defines it.
    let found = |q: &str| -> Value {
        let at = reqwest::Url::parse_with_params(
            &format!("{}/xrpc/dev.knotless.listItems", av.base),
            [
                (
                    "repo",
                    format!("at://{}/dev.knotless.repo/labelled", owner.account.handle).as_str(),
                ),
                ("kind", "issue"),
                ("q", q),
            ],
        );
        reqwest::blocking::get(at.unwrap()).unwrap().json().unwrap()
    };
    let listed = || found("");
    let defined = serde_json::json!([
        {"name": "kind/bug", "color": "#d73a4a", "description": "Something broken"},
        {"name": "priority/high", "exclusive": true},
    ]);
    wait_until(|| listed()["items"][0]["labels"] == defined);
    // A query filters them, and the counts are of those it matches; the
    // answer names what a filter can.
    let all = listed();
    assert_eq!(all["labels"].as_array().unwrap().len(), 3, "{all}");
    assert_eq!(all["labels"][2]["name"], "priority/low");
    assert_eq!(all["authors"][0]["handle"], author.account.handle.as_str());
    let counts = |q: &str| {
        let a = found(q);
        let shown = a["items"].as_array().unwrap().len();
        (
            shown,
            a["openCount"].as_u64().unwrap(),
            a["closedCount"].as_u64().unwrap(),
        )
    };
    let by = format!("author:{} label:kind/bug breaks", author.account.handle);
    assert_eq!(counts(&by), (1, 1, 0));
    assert_eq!(counts("label:kind/bug label:priority/low"), (0, 0, 0));
    assert_eq!(counts("-label:priority/high"), (0, 0, 0));
    assert_eq!(counts("author:nobody.invalid"), (0, 0, 0));
    assert_eq!(counts("state:closed label:kind/bug"), (0, 1, 0));
    assert_eq!(counts("is:merged"), (0, 1, 0));
    // The CLI reads the same words.
    let list = |args: &[&str]| {
        ok(
            &author,
            &[
                &[
                    if args.contains(&"--pulls") {
                        "pull"
                    } else {
                        "issue"
                    },
                    "list",
                    &url,
                ][..],
                &args
                    .iter()
                    .copied()
                    .filter(|a| *a != "--issues" && *a != "--pulls")
                    .collect::<Vec<_>>()[..],
            ]
            .concat(),
        )
    };
    assert!(list(&["--issues", "-q", "label:kind/bug"]).contains(&issue));
    assert!(list(&["-q", "label:priority/low"]).is_empty());
    assert!(list(&["--pulls", "-q", "label:kind/bug"]).is_empty());
    fails(&author, &["issue", "list", &url, "-q"]);
    fails(&author, &["issue", "list", &url, "--bogus"]);
    ok(
        &owner,
        &["account", "delegate", &owner.account.handle, &av.did],
    );
    ok(
        &owner,
        &["repo", "share", &url, &collab.account.handle, "--write"],
    );
    // A collaborator's exclusive label takes the owner's of its scope off,
    // and taking it off brings none of them back.
    let low = ok(&collab, &["issue", "label", &url, &issue, "priority/low"]);
    assert_eq!(low, "kind/bug\npriority/low");
    let off = ok(
        &collab,
        &["pull", "label", &url, &issue, "priority/low", "--undo"],
    );
    assert_eq!(off, "kind/bug");
    shows("labels: kind/bug");
    let high = ok(&owner, &["issue", "label", &url, &issue, "priority/high"]);
    assert_eq!(high, "kind/bug\npriority/high");
    shows("labels: kind/bug, priority/high");
    // A triager puts on labels of its scopes alone, and they give way to
    // a person's, whenever set.
    let bot = author.account.handle.as_str();
    let named = ok(&owner, &["triager", "add", &url, bot, "priority"]);
    assert_eq!(named, format!("{}\tpriority", author.account.did));
    assert_eq!(ok(&owner, &["triager", "list", &url]), named);
    fails(&author, &["issue", "label", &url, &issue, "kind/bug"]);
    let low = ok(&author, &["issue", "label", &url, &issue, "priority/low"]);
    assert_eq!(low, "kind/bug\npriority/high");
    for bad in [
        &["triager", "add", &url, bot][..],
        &["triager", "bogus", &url],
        &["triager", "list", &owner.url("nothing")],
        &["triager", "add", &owner.url("nothing"), bot, "x"],
    ] {
        fails(&owner, bad);
    }
    assert_eq!(ok(&owner, &["triager", "remove", &url, bot]), "");
    fails(&owner, &["triager", "remove", &url, bot]);
    // A label taken away comes off everything it was on.
    ok(&owner, &["label", "delete", &url, "kind/bug"]);
    shows("labels: priority/high");
}

#[test]
fn private_repositories_keep_their_issues_in_the_space() {
    if !net().spaces {
        eprintln!("skipped: the PDS is not on the spaces alpha");
        return;
    }
    let av = appview();
    let owner = person(account(), &av);
    let reader = person(account(), &av);
    let url = owner.url("inner");
    ok(&owner, &["repo", "create", &url, "--private"]);
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    ok(&owner, &["repo", "share", &url, &reader.account.handle]);

    let issue = ok(&reader, &["issue", "create", &url, "Private trouble"]);
    owner.git(&work, &["checkout", "-q", "-b", "fix"]);
    let fix = owner.commit_file(&work, "fix", "fixed\n");
    owner.git(&work, &["push", "-q", &url, "fix"]);
    let pull = ok(
        &owner,
        &["pull", "create", &url, "main", &url, "fix", "Fix it"],
    );
    ok(
        &reader,
        &["comment", "add", &url, &pull, "ship it", "--approve"],
    );
    // No appview involved: the members' repos in the space are read directly.
    let (_, listing) = both(&owner, &url);
    assert!(
        listing.contains(&format!("issue\topen\t{issue}")),
        "{listing}"
    );
    assert!(
        listing.contains(&format!("pull\topen\t{pull}")),
        "{listing}"
    );
    // What its author wrote in the space, they edit and take back there.
    ok(
        &reader,
        &[
            "issue",
            "edit",
            &url,
            &issue,
            "--title",
            "Private trouble, worse",
        ],
    );
    let said = ok(&reader, &["comment", "add", &url, &issue, "still broken"]);
    ok(
        &reader,
        &["pull", "edit", &url, &said, "-m", "still broken, sadly"],
    );
    fails(&owner, &["pull", "edit", &url, &said, "-m", "fine now"]);
    let shown = ok(&owner, &["issue", "show", &url, &issue]);
    assert!(shown.contains("Private trouble, worse"), "{shown}");
    assert!(shown.contains("still broken, sadly"), "{shown}");
    ok(&reader, &["comment", "delete", &url, &said]);
    assert!(!ok(&owner, &["issue", "show", &url, &issue]).contains("still broken"));
    // Its pulls and issues are in the space, which cannot move.
    fails(&owner, &["repo", "transfer", &url, &reader.account.handle]);
    // The owner's gate and blocks hold in the space, with no appview.
    ok(&owner, &["mod", "hide", &url, &issue]);
    assert!(!ok(&reader, &["issue", "list", &url]).contains(&issue));
    ok(&owner, &["mod", "hide", &url, &issue, "--undo"]);
    ok(&owner, &["mod", "block", &reader.account.handle]);
    assert!(!ok(&owner, &["issue", "list", &url]).contains(&issue));
    ok(&owner, &["mod", "block", &reader.account.handle, "--undo"]);
    assert!(ok(&owner, &["issue", "list", &url]).contains(&issue));
    // A block from this repository alone is in the space with it.
    ok(&owner, &["mod", "block", &reader.account.handle, &url]);
    assert!(!ok(&owner, &["issue", "list", &url]).contains(&issue));
    ok(
        &owner,
        &["mod", "block", &reader.account.handle, &url, "--undo"],
    );
    assert!(ok(&owner, &["issue", "list", &url]).contains(&issue));
    let merged = ok(&owner, &["pull", "merge", &url, &pull]);
    let (made, deleted) = merged.split_once('\n').unwrap();
    assert!(made != fix && deleted == "deleted fix", "{merged}");
    let listing = ok(&reader, &["pull", "list", &url]);
    assert!(
        listing.contains(&format!("pull\tmerged\t{pull}")),
        "{listing}"
    );
    // The appview never saw any of it.
    let uri = format!("at://{}/dev.knotless.repo/inner", owner.account.did);
    assert_eq!(av.app.index.as_ref().unwrap().repo(&uri).unwrap(), None);

    // A private repository forks into a private one.
    let fork = ok(&reader, &["repo", "fork", &url, "inner-fork"]);
    let (_, refs) = reader.cli(&["repo", "refs", &fork], b"");
    assert!(refs.contains(&format!("{made}\trefs/heads/main")), "{refs}");
    let anonymous = Sandbox::new();
    assert_eq!(anonymous.cli(&["repo", "refs", &fork], b"").0, 1);
    // Its source's owner reads it, to take a pull from it.
    let (code, refs) = owner.cli(&["repo", "refs", &fork], b"");
    assert_eq!(code, 0, "{refs}");
    let readers = ok(&reader, &["repo", "members", &fork]);
    assert!(readers.contains(owner.account.did.as_str()), "{readers}");
}

#[test]
fn moving_to_another_account() {
    let av = appview();
    let owner = person(account(), &av);
    let heir = person(account(), &av);
    let stranger = person(account(), &av);
    let url = owner.url("moving");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let before = ok(&owner, &["repo", "refs", &url]);
    let closed = ok(
        &stranger,
        &["issue", "create", &url, "Closed before the move"],
    );
    let open = ok(&stranger, &["issue", "create", &url, "Still open"]);
    ok(&owner, &["pull", "close", &url, &closed]);
    listed(&owner, &url, &format!("issue\tclosed\t{closed}"));

    // Nothing moves until it is offered, and only to whom it is offered.
    fails(&heir, &["repo", "accept", &url]);
    fails(&owner, &["repo", "transfer", &url, &owner.account.handle]);
    ok(&owner, &["repo", "transfer", &url, &heir.account.handle]);
    fails(&stranger, &["repo", "accept", &url]);
    ok(&owner, &["repo", "transfer", &url, "--undo"]);
    fails(&heir, &["repo", "accept", &url]);
    let elsewhere = format!("knotless://{}/moving", heir.account.handle);
    ok(&owner, &["repo", "transfer", &url, &elsewhere]);
    ok(&owner, &["repo", "transfer", &url, "--undo"]);
    // Never over a repository of the heir's by the name it is offered under.
    let held = heir.url("held");
    ok(&heir, &["repo", "create", &held]);
    let theirs = heir.record("dev.knotless.repo", "held");
    ok(&owner, &["repo", "transfer", &url, &held]);
    fails(&heir, &["repo", "accept", &url]);
    assert_eq!(heir.record("dev.knotless.repo", "held"), theirs);
    ok(&owner, &["repo", "transfer", &url, "--undo"]);
    fails(
        &owner,
        &[
            "repo",
            "transfer",
            &owner.url("nothing"),
            &heir.account.handle,
        ],
    );
    let offered = ok(&owner, &["repo", "transfer", &url, &heir.account.handle]);
    assert!(offered.contains("knotless repo accept"), "{offered}");
    let moved = ok(&heir, &["repo", "accept", &url]);
    let new = format!("knotless://{}/moving", heir.account.did);
    assert_eq!(moved, format!("moved to {new}"));

    // The old URL leads to the new place, for reads and pushes alike.
    assert_eq!(ok(&heir, &["repo", "refs", &url]), before);
    heir.git(heir.dir.path(), &["clone", "-q", &url, "clone"]);
    let clone = heir.path("clone");
    heir.commit_file(&clone, "g", "after the move\n");
    let (pushed, _, err) = heir.git_raw(&clone, &["push", "-q", "origin", "main"]);
    assert!(
        pushed && err.contains(&format!("has moved to {new}")),
        "{err}"
    );
    assert_ne!(ok(&heir, &["repo", "refs", &new]), before);
    // A query by the old owner answers under the new one.
    let asked = format!(
        "{}/xrpc/dev.knotless.getRepo?repo=at://{}/dev.knotless.repo/moving",
        av.base, owner.account.handle
    );
    let found: serde_json::Value = reqwest::blocking::get(asked).unwrap().json().unwrap();
    assert_eq!(found["owner"]["handle"], heir.account.handle.as_str());
    assert_eq!(found["owner"]["did"], heir.account.did.as_str());

    // Its issues came with it, and what the old owner did before the move
    // still counts; nothing they do after it does, so it is refused.
    listed(&heir, &new, &format!("issue\tclosed\t{closed}"));
    let (shut, _, err) = owner.knotless(&["pull", "close", &url, &open], b"");
    assert!(!shut && err.contains("may close it"), "{err}");
    listed(&heir, &new, &format!("issue\topen\t{open}"));
    ok(&heir, &["comment", "add", &new, &open, "moved along"]);
    ok(&heir, &["pull", "close", &new, &open]);
    listed(&heir, &new, &format!("issue\tclosed\t{open}"));

    // Archived, its pages say so, and nobody writes about it.
    ok(&heir, &["repo", "archive", &new]);
    fails(&heir, &["comment", "add", &new, &open, "too late"]);
    let asked = format!(
        "{}/xrpc/dev.knotless.getRepo?repo=at://{}/dev.knotless.repo/moving",
        av.base, heir.account.did
    );
    let archived = || {
        let found: Value = reqwest::blocking::get(&asked).unwrap().json().unwrap();
        found["archivedAt"].is_string()
    };
    wait_until(archived);
    ok(&heir, &["repo", "archive", &new, "--undo"]);
    wait_until(|| !archived());

    // The old copy can go, leaving a redirect in its place; its old names'
    // redirects lead to the new place too, and others' stay as they were.
    let key = owner.record("dev.knotless.repo", "moving")["key"].clone();
    let at = format!("at://{}/dev.knotless.repo/moving", owner.account.did);
    owner.put_record(
        "dev.knotless.redirect",
        "once-moving",
        serde_json::json!({"$type": "dev.knotless.redirect", "to": {"uri": at, "key": key},
            "createdAt": "2026-10-21T00:00:00.000Z"}),
    );
    ok(&owner, &["repo", "create", &owner.url("spare")]);
    ok(&owner, &["repo", "rename", &owner.url("spare"), "spare2"]);
    ok(&owner, &["repo", "delete", &url, "--yes"]);
    assert!(
        owner.record("dev.knotless.redirect", "once-moving")["to"]["uri"]
            .as_str()
            .unwrap()
            .starts_with(&format!("at://{}/", heir.account.did))
    );
    assert_eq!(
        owner.record("dev.knotless.redirect", "spare")["to"]["uri"],
        format!("at://{}/dev.knotless.repo/spare2", owner.account.did)
    );
    assert!(
        owner.record("dev.knotless.redirect", "moving")["to"]["uri"]
            .as_str()
            .unwrap()
            .starts_with(&format!("at://{}/", heir.account.did))
    );
    assert_eq!(
        ok(&stranger, &["repo", "refs", &url]),
        ok(&stranger, &["repo", "refs", &new])
    );
    listed(&stranger, &url, &format!("issue\tclosed\t{closed}"));
    // A raw file by the old owner is sent on to the heir's.
    let direct = reqwest::blocking::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .unwrap();
    let raw = |owner: &str| format!("/{owner}/moving/raw/main/a");
    let old_raw = || {
        let at = format!("{}{}", av.base, raw(&owner.account.handle));
        direct.get(at).send().unwrap()
    };
    wait_until(|| old_raw().status().as_u16() == 307);
    assert_eq!(
        old_raw().headers()["location"],
        raw(&heir.account.did).as_str()
    );
    // Its issues, asked of by the old owner, are the heir's.
    let old_issues = || -> Value {
        let at = reqwest::Url::parse_with_params(
            &format!("{}/xrpc/dev.knotless.listItems", av.base),
            [
                (
                    "repo",
                    format!("at://{}/dev.knotless.repo/moving", owner.account.handle).as_str(),
                ),
                ("kind", "issue"),
                ("state", "closed"),
            ],
        );
        reqwest::blocking::get(at.unwrap()).unwrap().json().unwrap()
    };
    wait_until(|| {
        let listed = old_issues()["items"]
            .as_array()
            .cloned()
            .unwrap_or_default();
        listed.iter().any(|i| i["uri"] == closed.trim())
    });

    // Saying a repository came from another proves nothing without its
    // owner's word: none of its issues come along.
    let other = owner.url("other");
    owner.git(&work, &["push", "-q", &other, "main"]);
    let theirs = ok(&stranger, &["issue", "create", &other, "Not yours"]);
    listed(&owner, &other, &theirs);
    let key = owner.record("dev.knotless.repo", "other")["key"].clone();
    let from = serde_json::json!({"uri": format!("at://{}/dev.knotless.repo/other", owner.account.did), "key": key});
    stranger.put_record(
        "dev.knotless.repo",
        "claimed",
        serde_json::json!({"$type": "dev.knotless.repo", "key": "3kabcdefghijk", "name": "claimed",
            "transferredFrom": from, "createdAt": "2026-10-21T00:00:00.000Z"}),
    );
    let claimed = stranger.url("claimed");
    std::thread::sleep(Duration::from_secs(1));
    assert!(!ok(&stranger, &["issue", "list", &claimed]).contains(&theirs));
}

/// Waits until `list` no longer shows `line`.
#[track_caller]
fn unlisted(s: &Sandbox, url: &str, line: &str) {
    wait_until(|| {
        let (ok, out) = both(s, url);
        ok && !out.contains(line)
    });
}

#[test]
fn moderating() {
    let operator = account();
    let av = appview_following(true, &operator.did);
    let owner = person(account(), &av);
    let troll = person(account(), &av);
    let reporter = person(account(), &av);
    let url = owner.url("moderated");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let trolling = ok(&troll, &["issue", "create", &url, "Trolling"]);
    let trolling = trolling.trim();
    let fair = ok(&reporter, &["issue", "create", &url, "A fair point"]);
    let fair = fair.trim();
    let reply = ok(&troll, &["comment", "add", &url, fair, "nonsense"]);
    let reply = reply.trim();
    listed(&owner, &url, trolling);

    // The owner hides an issue, and what was said about it goes with it.
    assert_eq!(ok(&owner, &["mod", "hide", &url, fair]), "hidden");
    assert_eq!(ok(&owner, &["mod", "hide", &url, fair]), "hidden");
    unlisted(&reporter, &url, fair);
    assert_eq!(reporter.cli(&["pull", "show", &url, fair], b"").0, 1);
    ok(&owner, &["mod", "hide", &url, reply]);
    assert_eq!(ok(&owner, &["mod", "hide", &url, fair, "--undo"]), "shown");
    listed(&reporter, &url, fair);
    assert!(!ok(&reporter, &["pull", "show", &url, fair]).contains("nonsense"));
    ok(&owner, &["mod", "hide", &url, reply, "--undo"]);
    wait_until(|| ok(&reporter, &["pull", "show", &url, fair]).contains("nonsense"));
    // Only the owner, and only what is said about a repository.
    fails(&troll, &["mod", "hide", &url, fair]);
    let repo_uri = format!("at://{}/dev.knotless.repo/moderated", owner.account.did);
    fails(&owner, &["mod", "hide", &url, &repo_uri]);

    // A block keeps out everything the account writes.
    let blocked = ok(&owner, &["mod", "block", &troll.account.handle]);
    assert_eq!(blocked, format!("blocked {}", troll.account.did));
    ok(&owner, &["mod", "block", &troll.account.handle]);
    let blocks = ok(&owner, &["mod", "blocks"]);
    let everywhere = format!("{}\tall your repositories\t", troll.account.handle);
    assert!(blocks.starts_with(&everywhere), "{blocks}");
    assert_eq!(blocks.lines().count(), 1);
    unlisted(&reporter, &url, trolling);
    wait_until(|| !ok(&reporter, &["pull", "show", &url, fair]).contains("nonsense"));
    fails(&owner, &["mod", "block", &owner.account.handle]);
    ok(&owner, &["mod", "block", &troll.account.handle, "--undo"]);
    listed(&reporter, &url, trolling);
    // Or keeps them out of one repository only, and only its owner can.
    ok(&owner, &["mod", "block", &troll.account.handle, &url]);
    ok(&owner, &["mod", "block", &troll.account.handle, &url]);
    let one = format!("{}\t{url}\t", troll.account.handle);
    assert!(ok(&owner, &["mod", "blocks"]).starts_with(&one));
    unlisted(&reporter, &url, trolling);
    // Nor does it show by its URI, however new.
    assert_eq!(reporter.cli(&["pull", "show", &url, trolling], b"").0, 1);
    fails(&reporter, &["mod", "block", &troll.account.handle, &url]);
    ok(
        &owner,
        &["mod", "block", &troll.account.handle, &url, "--undo"],
    );
    listed(&reporter, &url, trolling);

    // Reports go to the appview's moderation service, from an OAuth login
    // too, whose scope names the call.
    let handle = reporter.account.handle.clone();
    assert_eq!(reporter.cli(&["login", &handle], b"").0, 0);
    let sent = ok(
        &reporter,
        &[
            "mod",
            "report",
            trolling,
            "spam",
            "-m",
            "the same thing again",
        ],
    );
    assert_eq!(sent, "report 1 sent");
    ok(&reporter, &["mod", "report", &troll.account.handle, "rude"]);
    ok(&reporter, &["mod", "report", &url, "other"]);
    fails(&reporter, &["mod", "report", &url, "boring"]);
    fails(
        &reporter,
        &["mod", "report", &url, "spam", "--image", "shot.png"],
    );
    fails(
        &reporter,
        &["mod", "report", &format!("{trolling}x"), "spam"],
    );
    // Only an operator reads them.
    fails(&reporter, &["admin", "reports"]);
    let admin = person(operator, &av);
    let reports = ok(&admin, &["admin", "reports"]);
    let lines: Vec<&str> = reports.lines().collect();
    assert_eq!(lines.len(), 3, "{reports}");
    assert!(
        lines[0].starts_with("3\t") && lines[0].contains(&repo_uri),
        "{reports}"
    );
    assert!(
        lines[1].contains(&format!("rude\t{}", troll.account.did)),
        "{reports}"
    );
    assert_eq!(
        lines[2],
        format!(
            "1\t{}\tspam\t{trolling}\tthe same thing again",
            reporter.account.did
        )
    );
    assert_eq!(ok(&admin, &["admin", "reports", "1"]).lines().count(), 1);
    fails(&admin, &["admin", "reports", "x"]);
    fails(&admin, &["admin", "nothing"]);
    // Without an appview, reports go where KNOTLESS_MODERATOR says.
    let mut direct = person(reporter.account.clone(), &av);
    direct.set("KNOTLESS_APPVIEW", "");
    fails(&direct, &["mod", "report", trolling, "spam"]);
    direct.set("KNOTLESS_MODERATOR", &owner.account.did);
    fails(&direct, &["mod", "report", trolling, "spam"]);
    fails(&reporter, &["mod", "report", &owner.url("nothing"), "spam"]);
    direct.set("KNOTLESS_MODERATOR", &av.did);
    assert_eq!(
        ok(&direct, &["mod", "report", trolling, "spam"]),
        "report 4 sent"
    );

    // A second appview follows this one's labels.
    let other = appview();
    other.rt.spawn(knotless_serve::moderation::follow(
        other.app.clone(),
        av.did.clone(),
    ));
    let down_there = |subject: &str| other.app.index.as_ref().unwrap().down(&[subject]).unwrap();

    // The operator takes things down.
    assert_eq!(ok(&admin, &["admin", "status", trolling]), "up");
    assert_eq!(ok(&admin, &["admin", "takedown", trolling]), "taken down");
    assert_eq!(ok(&admin, &["admin", "status", trolling]), "taken down");
    unlisted(&reporter, &url, trolling);
    wait_until(|| down_there(trolling));
    fails(&admin, &["admin", "takedown", trolling, "--later"]);
    assert_eq!(
        ok(&admin, &["admin", "takedown", trolling, "--undo"]),
        "restored"
    );
    listed(&reporter, &url, trolling);
    wait_until(|| !down_there(trolling));

    // An account taken down takes its repositories with it.
    let page = || {
        reqwest::blocking::get(format!(
            "{}/xrpc/dev.knotless.getRepo?repo=at://{}/dev.knotless.repo/moderated",
            av.base, owner.account.handle
        ))
        .unwrap()
        .status()
        .as_u16()
    };
    assert_eq!(page(), 200);
    ok(&admin, &["admin", "takedown", &owner.account.handle]);
    assert_eq!(
        ok(&admin, &["admin", "status", &owner.account.handle]),
        "taken down"
    );
    assert_eq!(page(), 404);
    let listing = reqwest::blocking::get(format!("{}/xrpc/dev.knotless.listRepos", av.base))
        .unwrap()
        .text()
        .unwrap();
    assert!(!listing.contains(&owner.account.did), "{listing}");
    let labels: Value = reqwest::blocking::get(format!(
        "{}/xrpc/com.atproto.label.queryLabels?uriPatterns=did:*&uriPatterns={}",
        av.base, trolling
    ))
    .unwrap()
    .json()
    .unwrap();
    assert_eq!(labels["labels"][0]["uri"], owner.account.did.as_str());
    assert_eq!(labels["labels"][0]["val"], "!takedown");
    assert_eq!(labels["labels"].as_array().unwrap().len(), 1);
    wait_until(|| down_there(&owner.account.did));
    ok(&admin, &["admin", "takedown", &url]);
    assert_eq!(ok(&admin, &["admin", "status", &url]), "taken down");
    ok(
        &admin,
        &["admin", "takedown", &owner.account.handle, "--undo"],
    );
    assert_eq!(page(), 404);
    ok(&admin, &["admin", "takedown", &url, "--undo"]);
    assert_eq!(page(), 200);

    // The calls themselves, by hand.
    let client = reqwest::blocking::Client::new();
    let xrpc = |nsid: &str| format!("{}/xrpc/{nsid}", av.base);
    let post = |nsid: &str, auth: Option<&str>, body: Value| {
        let mut req = client.post(xrpc(nsid)).json(&body);
        if let Some(auth) = auth {
            req = req.header("authorization", auth);
        }
        req.send().unwrap().status().as_u16()
    };
    // An operator's calls carry a service token for the method, from
    // their PDS.
    let token = |who: &Sandbox, lxm: &str| {
        let ctx = ctx(who);
        let token = av.rt.block_on(async {
            let me = knotless::session::me(&ctx).await.unwrap();
            knotless::auth::service_token(&ctx, &me, &av.did, lxm)
                .await
                .unwrap()
        });
        format!("Bearer {token}")
    };
    let status = "com.atproto.admin.updateSubjectStatus";
    let operating = token(&admin, status);
    assert_eq!(post(status, None, serde_json::json!({})), 401);
    assert_eq!(
        post(status, Some(&operating), serde_json::json!({"subject": {}})),
        400
    );
    let account =
        serde_json::json!({"$type": "com.atproto.admin.defs#repoRef", "did": "did:plc:x"});
    let down = serde_json::json!({"subject": account});
    assert_eq!(post(status, Some(&operating), down.clone()), 200);
    // Not from anyone else, nor with a token for another method.
    assert_eq!(
        post(status, Some(&token(&reporter, status)), down.clone()),
        403
    );
    let report = "com.atproto.moderation.createReport";
    assert_eq!(post(status, Some(&token(&admin, report)), down), 401);
    let get_status = "com.atproto.admin.getSubjectStatus";
    let looking = token(&admin, get_status);
    let get_status = |q: &str| {
        client
            .get(xrpc(&format!("{get_status}{q}")))
            .header("authorization", &looking)
            .send()
            .unwrap()
            .status()
            .as_u16()
    };
    assert_eq!(get_status(""), 400);
    assert_eq!(get_status("?did=did:plc:x"), 200);
    assert_eq!(post(report, None, serde_json::json!({})), 401);
    let bearer = token(&reporter, report);
    assert_eq!(
        post(
            report,
            Some(&bearer),
            serde_json::json!({"subject": account})
        ),
        400
    );
    assert_eq!(
        post(report, Some(&bearer), serde_json::json!({"subject": {}})),
        400
    );
    let page2 = client
        .get(xrpc("dev.knotless.listReports?limit=2"))
        .header("authorization", token(&admin, "dev.knotless.listReports"))
        .send()
        .unwrap()
        .json::<Value>()
        .unwrap();
    assert_eq!(page2["cursor"], "3");

    // The label stream, from the start: everything given out so far, then
    // what comes; from past the end, only what comes after it.
    let stream = |cursor: Option<i64>| {
        let url = format!(
            "{}/xrpc/com.atproto.label.subscribeLabels{}",
            av.base.replace("http", "ws"),
            cursor.map(|c| format!("?cursor={c}")).unwrap_or_default()
        );
        av.rt
            .block_on(tokio_tungstenite::connect_async(url))
            .unwrap()
            .0
    };
    let (mut from_start, mut from_end) = (stream(Some(0)), stream(Some(1000)));
    let next = |ws: &mut tokio_tungstenite::WebSocketStream<_>| {
        let msg = av.rt.block_on(ws.next()).unwrap().unwrap();
        knotless_serve::moderation::parse(&msg.into_data()).unwrap()
    };
    assert_eq!(next(&mut from_start).0, 1);
    for _ in 2..=6 {
        next(&mut from_start);
    }
    ok(&admin, &["admin", "takedown", trolling]);
    let (seq, labels) = next(&mut from_start);
    assert_eq!((seq, labels[0]["uri"].as_str()), (7, Some(trolling)));
    let ping = tokio_tungstenite::tungstenite::Message::Ping(vec![].into());
    av.rt.block_on(from_start.send(ping)).unwrap();
    av.rt.block_on(from_start.close(None)).unwrap();
    av.rt.block_on(from_end.close(None)).unwrap();
    av.rt.block_on(stream(None).close(None)).unwrap();
    ok(&admin, &["admin", "takedown", trolling, "--undo"]);
    // Labels follow from where they were read to.
    let labels = other.app.index.as_ref().unwrap();
    assert!(labels.labeler_seq(&av.did).unwrap().is_some());
    // One taken down as soon as it is opened does not show by its URI,
    // indexed yet or not.
    let fresh = ok(&troll, &["issue", "create", &url, "Again"]);
    let fresh = fresh.trim();
    ok(&admin, &["admin", "takedown", fresh]);
    assert_eq!(reporter.cli(&["pull", "show", &url, fresh], b"").0, 1);
}

#[test]
fn the_merge_queue_is_for_who_may_merge() {
    let av = appview();
    let owner = person(account(), &av);
    let contrib = person(account(), &av);
    let stranger = person(account(), &av);
    let url = owner.url("queued");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let pull = |s: &Sandbox, branch: &str| {
        owner.git(&work, &["checkout", "-q", "-B", branch, "main"]);
        owner.commit_file(&work, branch, "x\n");
        owner.git(&work, &["push", "-q", &url, branch]);
        let pull = ok(s, &["pull", "create", &url, "main", &url, branch, branch]);
        listed(&owner, &url, &pull);
        pull
    };
    let x = pull(&owner, "x");
    let refused = |who: &Sandbox, args: &[&str], why: &str| {
        let (done, _, err) = who.knotless(args, b"");
        assert!(!done && err.contains(why), "{args:?}: {err}");
    };
    assert_eq!(
        ok(&owner, &["queue", "list", &url]),
        "the merge queue is empty"
    );
    // With no runner to land it, there is no queue.
    refused(
        &owner,
        &["pull", "merge", &url, &x, "--queue"],
        "names no runners",
    );
    ok(&owner, &["runner", "add", &url, &contrib.account.handle]);
    // Nor is there one until a rule of the branch turns it on.
    refused(
        &owner,
        &["pull", "merge", &url, &x, "--queue"],
        "main does not use the merge queue",
    );
    refused(&owner, &["pull", "merge", &url, &x, "--queue"], "--queue`");
    ok(&owner, &["branch", "protect", &url, "main", "--queue"]);
    refused(
        &owner,
        &["pull", "merge", &url, &x, "--queue", "--wait", "5"],
        "--queue takes none of",
    );
    // Someone who may not merge it may not queue it, nor take it out.
    refused(
        &contrib,
        &["pull", "merge", &url, &x, "--queue"],
        "only the owner and collaborators",
    );
    refused(
        &contrib,
        &["queue", "leave", &url, &x],
        "only the owner and collaborators",
    );
    let key = owner.record("dev.knotless.repo", "queued")["key"].clone();
    let repo = serde_json::json!({
        "uri": format!("at://{}/dev.knotless.repo/queued", owner.account.did),
        "key": key,
    });
    let record = |state: &str| {
        serde_json::json!({
            "$type": "dev.knotless.queue", "repo": repo, "subject": x,
            "state": state, "reason": "why not", "createdAt": "2026-10-21T00:00:00.000Z",
        })
    };

    // The owner queues it, and again: it keeps its place.
    let queued = ok(&owner, &["pull", "merge", &url, &x, "--queue"]);
    assert!(
        queued.starts_with("queued into main: 1 of 1\nA runner lands it"),
        "{queued}"
    );
    wait_until(|| ok(&owner, &["queue", "list", &url]).contains(&x));
    stranger.put_record("dev.knotless.queue", &knotless::keys::tid(), record("left"));
    assert!(
        ok(&owner, &["pull", "merge", &url, &x, "--queue"])
            .starts_with("queued into main: 1 of 1\n")
    );
    assert_eq!(
        ok(&owner, &["queue", "list", &url]),
        format!("1\tmain\tqueued\t{x}\tx")
    );
    let listing: Value =
        serde_json::from_str(&ok(&owner, &["queue", "list", &url, "--json"])).unwrap();
    assert_eq!(listing[0]["queue"]["by"], owner.account.did.as_str());
    assert_eq!(
        listing[0]["place"],
        serde_json::json!({"base": "main", "position": 1, "of": 1})
    );
    let shown = ok(&owner, &["pull", "show", &url, &x]);
    assert!(shown.contains("\nmerge queue: queued at "), "{shown}");
    // With the rule gone it keeps its place, and may still leave.
    ok(&owner, &["branch", "protect", &url, "main", "--undo"]);
    let checked = ok(&owner, &["pull", "merge", &url, &x, "--check"]);
    assert!(checked.contains(",queue,"), "{checked}");
    assert!(
        ok(&owner, &["pull", "merge", &url, &x, "--queue"])
            .starts_with("queued into main: 1 of 1\n")
    );
    ok(&owner, &["branch", "protect", &url, "main", "--queue"]);

    // A collaborator may too, behind it; not an issue, or a pull that is
    // not open.
    let mut named = owner.record("dev.knotless.repo", "queued");
    named["collaborators"] = serde_json::json!([{"did": contrib.account.did}]);
    owner.put_record("dev.knotless.repo", "queued", named);
    let y = pull(&contrib, "y");
    let queued = ok(&contrib, &["pull", "merge", &url, &y, "--queue"]);
    assert!(queued.starts_with("queued into main: 2 of 2\n"), "{queued}");
    let issue = ok(&owner, &["issue", "create", &url, "Not a pull"]);
    listed(&owner, &url, &issue);
    refused(
        &owner,
        &["pull", "merge", &url, &issue, "--queue"],
        "is an issue",
    );
    // Taken out, it is not in the queue to take out again. What a
    // stranger wrote counts for nothing.
    wait_until(|| ok(&owner, &["queue", "list", &url]).contains(&y));
    let line = ok(&owner, &["queue", "list", &url]);
    assert!(
        line.starts_with(&format!("1\tmain\tqueued\t{x}\tx\n2\tmain\tqueued\t{y}")),
        "{line}"
    );
    assert_eq!(
        ok(&contrib, &["queue", "leave", &url, &y]),
        "left the merge queue"
    );
    wait_until(|| !ok(&owner, &["queue", "list", &url]).contains(&y));
    refused(
        &contrib,
        &["queue", "leave", &url, &y],
        "is not in the merge queue",
    );
    ok(&owner, &["pull", "close", &url, &y]);
    listed(&owner, &url, &format!("pull\tclosed\t{y}"));
    refused(&owner, &["pull", "merge", &url, &y, "--queue"], "is closed");
    let (code, out) = owner.cli(&["queue", "list", &url, "--bogus"], b"");
    assert_eq!(code, 1, "{out}");

    // Ejected, as a runner says, it shows why.
    owner.put_record(
        "dev.knotless.queue",
        &knotless::keys::tid(),
        record("ejected"),
    );
    wait_until(|| ok(&owner, &["queue", "list", &url]).contains("ejected"));
    assert_eq!(
        ok(&owner, &["queue", "list", &url]),
        format!("-\tmain\tejected\t{x}\tx\n\tEjected: why not")
    );
    // The queue reads it from its writer's repo, the thread from the
    // index, which hears of it a moment after.
    wait_until(|| ok(&owner, &["pull", "show", &url, &x]).contains("queue: ejected"));
    let shown = ok(&owner, &["pull", "show", &url, &x]);
    assert!(
        shown.contains("\nmerge queue: ejected at 2026-10-21T00:00:00.000Z\nEjected: why not"),
        "{shown}"
    );

    // One that could only be ejected is not let in: one that conflicts
    // with the branch as it is, saying where, and one whose checks failed
    // at its head merged with it, until they pass.
    owner.git(&work, &["checkout", "-q", "-B", "clash", "main"]);
    owner.commit_file(&work, "c", "theirs\n");
    owner.git(&work, &["push", "-q", &url, "clash"]);
    owner.git(&work, &["checkout", "-q", "main"]);
    owner.commit_file(&work, "c", "ours\n");
    owner.git(&work, &["push", "-q", &url, "main"]);
    let clash = ok(
        &owner,
        &["pull", "create", &url, "main", &url, "clash", "clash"],
    );
    listed(&owner, &url, &clash);
    let queueing = ["pull", "merge", &url, &clash, "--queue"];
    refused(&owner, &queueing, "does not apply on main at ");
    refused(&owner, &queueing, "it conflicts in c. Merge main into it");
    let z = pull(&owner, "z");
    let tip = owner.git(&work, &["rev-parse", "main"]);
    let head = owner.git(&work, &["rev-parse", "z"]);
    let key = key.as_str().unwrap();
    let check = |status: &str, second: u8| {
        let rkey = knotless::keys::check_rkey(&owner.account.did, key, &z, &head, Some(&tip), "ci");
        let record = serde_json::json!({
            "$type": "dev.knotless.check", "repo": repo, "ref": "refs/heads/z",
            "pull": z, "commit": head, "baseCommit": tip, "workflow": "ci",
            "status": status, "startedAt": format!("2026-10-21T00:01:{second:02}.000Z"),
        });
        contrib.put_record("dev.knotless.check", &rkey, record);
    };
    check("failure", 1);
    let queueing = ["pull", "merge", &url, &z, "--queue"];
    wait_until(|| {
        owner
            .knotless(&queueing, b"")
            .2
            .contains("did not pass its checks")
    });
    refused(
        &owner,
        &queueing,
        ": ci (failure). Fix it and queue it again",
    );
    check("success", 2);
    wait_until(|| owner.knotless(&queueing, b"").0);
}

/// Merge when checks pass: set by who may merge, on a branch with no queue,
/// cancelled by them or the pull's author, and by a push from anyone who
/// may not merge it, or by retargeting it. `pull show` says which.
#[test]
fn merge_when_checks_pass_is_set_by_who_may_merge() {
    let av = appview();
    let owner = person(account(), &av);
    let contrib = person(account(), &av);
    let stranger = person(account(), &av);
    let url = owner.url("auto");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let branch = |name: &str| {
        owner.git(&work, &["checkout", "-q", "-B", name, "main"]);
        owner.commit_file(&work, name, "x\n");
        owner.git(&work, &["push", "-q", &url, name]);
    };
    branch("x");
    let x = ok(&owner, &["pull", "create", &url, "main", &url, "x", "x"]);
    let x = x.trim().to_string();
    listed(&owner, &url, &x);
    let refused = |who: &Sandbox, args: &[&str], why: &str| {
        let (done, _, err) = who.knotless(args, b"");
        assert!(!done && err.contains(why), "{args:?}: {err}");
    };
    let auto = ["pull", "merge", &url, &x, "--auto"];
    refused(&owner, &auto, "names no runners");
    ok(&owner, &["runner", "add", &url, &stranger.account.handle]);
    refused(
        &owner,
        &["pull", "merge", &url, &x, "--undo"],
        "--undo cancels --auto",
    );
    refused(
        &owner,
        &["pull", "merge", &url, &x, "--auto", "--wait", "5"],
        "--auto takes none of",
    );
    refused(&contrib, &auto, "only the owner and collaborators");
    // A branch with the queue holds a pull until its checks pass anyway.
    ok(&owner, &["branch", "protect", &url, "main", "--queue"]);
    refused(&owner, &auto, "takes pulls through the merge queue");
    ok(&owner, &["branch", "protect", &url, "main", "--undo"]);
    branch("d");
    let opening = ["pull", "create", &url, "main", &url, "d", "D", "--draft"];
    let d = ok(&owner, &opening);
    listed(&owner, &url, d.trim());
    refused(
        &owner,
        &["pull", "merge", &url, d.trim(), "--auto"],
        "is a draft",
    );

    // Set, it says so, and by whom.
    let set = ok(&owner, &auto);
    assert!(
        set.starts_with("set to merge when its checks pass, at "),
        "{set}"
    );
    let says = |who: &Sandbox, pull: &str, what: &str| {
        wait_until(|| ok(who, &["pull", "show", &url, pull]).contains(what));
    };
    let by = format!("merge when checks pass: set by {}", owner.account.handle);
    says(&owner, &x, &by);
    // A stranger may not cancel it; the owner may, once.
    let undo = ["pull", "merge", &url, &x, "--auto", "--undo"];
    refused(&stranger, &undo, "and the pull's author, cancel");
    let cancelled = ok(&owner, &undo);
    assert!(cancelled.starts_with("cancelled"), "{cancelled}");
    wait_until(|| !ok(&owner, &["pull", "show", &url, &x]).contains("merge when checks pass"));
    refused(&owner, &undo, "is not set to merge when its checks pass");

    // Moved to another branch, it is cancelled.
    branch("release");
    ok(&owner, &auto);
    ok(&owner, &["pull", "retarget", &url, &x, "--base", "release"]);
    says(&owner, &x, "cancelled, as the pull moved off main");

    // A fork's pull: its author's push cancels it, as they may not merge
    // it; the author may cancel it too.
    let fork = ok(&contrib, &["repo", "fork", &url]);
    let fork = fork.trim();
    let clone = contrib.path("clone");
    contrib.git(contrib.dir.path(), &["clone", "-q", fork, "clone"]);
    contrib.git(&clone, &["checkout", "-q", "-b", "f"]);
    contrib.commit_file(&clone, "f", "one\n");
    contrib.git(&clone, &["push", "-q", "origin", "f"]);
    let f = ok(&contrib, &["pull", "create", &url, "main", fork, "f", "F"]);
    let f = f.trim().to_string();
    listed(&owner, &url, &f);
    ok(&owner, &["pull", "merge", &url, &f, "--auto"]);
    says(&contrib, &f, &format!("set by {}", owner.account.handle));
    contrib.commit_file(&clone, "f", "two\n");
    contrib.git(&clone, &["push", "-q", "origin", "f"]);
    let pushed = format!(
        "cancelled, as {} pushed after {} set it",
        contrib.account.handle, owner.account.handle
    );
    says(&owner, &f, &pushed);
    // Gauged for the pages as its push left it: cancelled.
    let (_, checked, _) = owner.knotless(&["pull", "merge", &url, &f, "--check"], b"");
    assert!(
        !checked.contains("merges when its checks pass"),
        "{checked}"
    );
    let undone = ok(&contrib, &["pull", "merge", &url, &f, "--auto", "--undo"]);
    assert!(undone.starts_with("cancelled"), "{undone}");
    wait_until(|| !ok(&owner, &["pull", "show", &url, &f]).contains("merge when checks pass"));
}

#[test]
fn assignees_and_reviews_maintainers_ask_for() {
    let av = appview();
    let owner = person(account(), &av);
    let collab = person(account(), &av);
    let author = person(account(), &av);
    let url = owner.url("assigned");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    owner.git(&work, &["checkout", "-q", "-B", "a", "main"]);
    owner.commit_file(&work, "a", "x\n");
    owner.git(&work, &["push", "-q", &url, "a"]);
    ok(
        &owner,
        &["account", "delegate", &owner.account.handle, &av.did],
    );
    ok(
        &owner,
        &["repo", "share", &url, &collab.account.handle, "--write"],
    );
    let refused = |who: &Sandbox, args: &[&str], why: &str| {
        let (done, _, err) = who.knotless(args, b"");
        assert!(!done && err.contains(why), "{args:?}: {err}");
    };
    let (mine, theirs) = (&owner.account.handle, &collab.account.handle);
    let issue = ok(&author, &["issue", "create", &url, "Broken"]);
    let pull = ok(
        &author,
        &["pull", "create", &url, "main", &url, "a", "Fix it"],
    );
    let comment = ok(&author, &["comment", "add", &url, &issue, "Still broken"]);

    // The owner and collaborators assign, and only pulls and issues.
    refused(
        &owner,
        &["issue", "assign", &url, &issue],
        "name an account",
    );
    refused(
        &author,
        &["issue", "assign", &url, &issue, mine],
        "only the owner and collaborators",
    );
    refused(
        &owner,
        &["pull", "assign", &url, &comment, mine],
        "is not a pull or an issue",
    );
    refused(
        &owner,
        &["pull", "ask-review", &url, &issue, theirs],
        "is not a pull",
    );
    // One by one: assigned at once, they stand in the order of their DIDs.
    assert_eq!(
        ok(&owner, &["issue", "assign", &url, &issue, theirs]),
        theirs.as_str()
    );
    assert_eq!(
        ok(&owner, &["issue", "assign", &url, &issue, mine]),
        format!("{theirs}\n{mine}")
    );
    assert_eq!(
        ok(&collab, &["pull", "assign", &url, &issue, mine, "--undo"]),
        theirs.as_str()
    );
    wait_until(|| {
        let shown = author.cli(&["issue", "show", &url, &issue], b"").1;
        shown.contains(&format!("\nassignees: {theirs}\n"))
    });
    let found =
        |who: &Sandbox, q: &str| ok(who, &["issue", "list", &url, "-q", q]).contains(&issue);
    assert!(found(&collab, "assignee:@me") && !found(&owner, "assignee:@me"));
    assert!(found(&owner, &format!("assignee:{theirs}")) && !found(&owner, "no:assignee"));
    // Whom it is assigned to hears of it.
    let heard = |who: &Sandbox| who.cli(&["notification", "list", "--peek"], b"").1;
    wait_until(|| heard(&collab).contains("assigned"));

    // A maintainer asks for a review besides the author, never of the
    // author; the author stops asking only those it asked.
    let them = author.account.handle.as_str();
    refused(
        &owner,
        &["pull", "ask-review", &url, &pull, them],
        "reviews no pull of their own",
    );
    assert_eq!(
        ok(&owner, &["pull", "ask-review", &url, &pull, theirs]),
        theirs.as_str()
    );
    // The author reads the owner's ask through the appview, once it
    // hears of it; asking again asks no more.
    let both = format!("{mine}\n{theirs}");
    wait_until(|| ok(&author, &["pull", "ask-review", &url, &pull, mine]) == both);
    refused(
        &author,
        &["pull", "ask-review", &url, &pull, theirs, "--undo"],
        "asked by the owner or a collaborator",
    );
    refused(
        &collab,
        &["pull", "ask-review", &url, &pull, mine, "--undo"],
        "asked by the pull's author",
    );
    wait_until(|| heard(&collab).contains("review-requested"));
    // Each finds what concerns it, across repositories, with where it is.
    let concerning = |who: &Sandbox, rest: &str| {
        let did = &who.account.did;
        let at = format!(
            "{}/xrpc/dev.knotless.listActorItems?actor={did}&{rest}",
            av.base
        );
        reqwest::blocking::get(at).unwrap()
    };
    let titles = |who: &Sandbox, rest: &str| -> Vec<String> {
        let page: Value = concerning(who, rest).json().unwrap();
        let items = page["items"].as_array().unwrap();
        let title = |i: &Value| i["item"]["title"].as_str().unwrap().to_string();
        items.iter().map(title).collect()
    };
    assert_eq!(titles(&author, "kind=issue"), ["Broken"]);
    assert_eq!(titles(&author, "kind=pull"), ["Fix it"]);
    assert!(titles(&author, "kind=issue&state=closed").is_empty());
    assert!(titles(&collab, "kind=issue").is_empty());
    assert_eq!(titles(&collab, "kind=issue&relation=assigned"), ["Broken"]);
    assert_eq!(titles(&collab, "kind=pull&relation=review"), ["Fix it"]);
    let page: Value = concerning(&collab, "kind=pull&relation=review")
        .json()
        .unwrap();
    assert_eq!(page["items"][0]["repo"]["name"], "assigned");
    assert_eq!(page["items"][0]["repo"]["did"], owner.account.did.as_str());
    assert_eq!(page["items"][0]["repo"]["handle"], mine.as_str());
    assert!(page["cursor"].is_null());
    // Across repositories, newest first, a page at a time.
    let another = owner.url("assigned-too");
    ok(&owner, &["repo", "create", &another]);
    ok(&author, &["issue", "create", &another, "Also broken"]);
    wait_until(|| titles(&author, "kind=issue").len() == 2);
    assert_eq!(titles(&author, "kind=issue"), ["Also broken", "Broken"]);
    let first: Value = concerning(&author, "kind=issue&limit=1").json().unwrap();
    assert_eq!(first["items"][0]["item"]["title"], "Also broken");
    assert_eq!(first["cursor"], "1");
    assert_eq!(titles(&author, "kind=issue&limit=1&cursor=1"), ["Broken"]);
    assert_eq!(concerning(&author, "kind=star").status(), 400);
    assert_eq!(concerning(&author, "kind=pull&cursor=x").status(), 400);
    let unknown = format!(
        "{}/xrpc/dev.knotless.listActorItems?actor=nobody.invalid&kind=pull",
        av.base
    );
    assert_eq!(reqwest::blocking::get(unknown).unwrap().status(), 400);
    assert_eq!(
        ok(
            &collab,
            &["pull", "ask-review", &url, &pull, theirs, "--undo"]
        ),
        mine.as_str()
    );
    assert_eq!(
        ok(
            &author,
            &["pull", "ask-review", &url, &pull, mine, "--undo"]
        ),
        ""
    );

    // An owner's own pull asks in itself, and stopping stops their
    // fellow maintainers' asks too.
    let own = ok(&owner, &["pull", "create", &url, "main", &url, "a", "Mine"]);
    assert_eq!(
        ok(&collab, &["pull", "ask-review", &url, &own, theirs]),
        theirs.as_str()
    );
    assert_eq!(
        ok(&owner, &["pull", "ask-review", &url, &own, theirs]),
        theirs.as_str()
    );
    assert_eq!(
        ok(
            &owner,
            &["pull", "ask-review", &url, &own, theirs, "--undo"]
        ),
        ""
    );
}

#[test]
fn maintainers_dismiss_reviews() {
    let av = appview();
    let owner = person(account(), &av);
    let collab = person(account(), &av);
    let bot = person(account(), &av);
    let url = owner.url("dismissed");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    owner.git(&work, &["checkout", "-q", "-B", "a", "main"]);
    owner.commit_file(&work, "a", "x\n");
    owner.git(&work, &["push", "-q", &url, "a"]);
    ok(
        &owner,
        &["repo", "share", &url, &collab.account.handle, "--write"],
    );
    let gate = bot.account.handle.as_str();
    ok(&owner, &["branch", "protect", &url, "main", "--gate", gate]);
    let refused = |who: &Sandbox, args: &[&str], why: &str| {
        let (done, _, err) = who.knotless(args, b"");
        assert!(!done && err.contains(why), "{args:?}: {err}");
    };
    // The gate asks for changes to the owner's own pull, whose approval
    // would not count.
    let pull = ok(&owner, &["pull", "create", &url, "main", &url, "a", "Mine"]);
    let issue = ok(&owner, &["issue", "create", &url, "Broken"]);
    // The gate is asked of it as its required reviewer, and stays asked.
    let shown = ok(&collab, &["pull", "show", &url, &pull]);
    assert!(
        shown.contains(&format!("review requested: {gate}")),
        "{shown}"
    );
    refused(
        &owner,
        &["pull", "ask-review", &url, &pull, gate, "--undo"],
        "is a required reviewer for main",
    );
    // The pages mark it so.
    let listed = reqwest::Url::parse_with_params(
        &format!("{}/xrpc/dev.knotless.listItems", av.base),
        [
            (
                "repo",
                format!("at://{}/dev.knotless.repo/dismissed", owner.account.did).as_str(),
            ),
            ("kind", "pull"),
        ],
    );
    let listed = listed.unwrap();
    let required = || -> Value {
        let page: Value = reqwest::blocking::get(listed.clone())
            .unwrap()
            .json()
            .unwrap();
        page["items"][0]["requiredReviewers"][0]["did"].clone()
    };
    wait_until(|| required() == bot.account.did.as_str());
    ok(
        &bot,
        &["comment", "add", &url, &pull, "Not yet", "--changes"],
    );
    let blocked = |who: &Sandbox| {
        let checked = ok(who, &["pull", "merge", &url, &pull, "--check"]);
        checked.contains("reviews blocked")
    };
    wait_until(|| blocked(&owner));

    let theirs = collab.account.handle.as_str();
    refused(
        &owner,
        &["pull", "dismiss-review", &url, &pull, gate, " "],
        "say why",
    );
    refused(
        &owner,
        &["pull", "dismiss-review", &url, &issue, gate, "No"],
        "is not a pull",
    );
    refused(
        &bot,
        &["pull", "dismiss-review", &url, &pull, gate, "No"],
        "only the owner and collaborators",
    );
    refused(
        &owner,
        &["pull", "dismiss-review", &url, &pull, theirs, "No"],
        "has no standing review",
    );
    // The owner dismisses it, saying why: it counts for nothing, and the
    // gate holds the pull no more.
    let dismissal = ok(
        &owner,
        &[
            "pull",
            "dismiss-review",
            &url,
            &pull,
            gate,
            "Fixed in review",
        ],
    );
    assert!(dismissal.contains("/dev.knotless.comment/"), "{dismissal}");
    // Another reads the dismissal once the appview hears of it.
    let show = || ok(&collab, &["pull", "show", &url, &pull]);
    wait_until(|| show().contains(", dismissed\n"));
    let shown = show();
    assert!(
        shown.contains(&format!("review: {gate} changes at ")),
        "{shown}"
    );
    assert!(!blocked(&collab));
    refused(
        &collab,
        &["pull", "dismiss-review", &url, &pull, gate, "Again"],
        "has no standing review",
    );
    // Its next review stands again, once the appview hears of it.
    let review = ok(
        &bot,
        &["comment", "add", &url, &pull, "Still not", "--changes"],
    );
    wait_until(|| blocked(&owner));
    // Named by the review's own URI, as well as by its reviewer.
    let review = review.lines().next().unwrap();
    ok(
        &collab,
        &["pull", "dismiss-review", &url, &pull, review, "Fine by me"],
    );
    assert!(!blocked(&owner));
}

#[test]
fn drafts_and_requested_reviewers() {
    let av = appview();
    let owner = person(account(), &av);
    let contrib = person(account(), &av);
    let reviewer = person(account(), &av);
    let url = owner.url("drafts");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    owner.git(&work, &["checkout", "-q", "-B", "d", "main"]);
    owner.commit_file(&work, "d", "x\n");
    owner.git(&work, &["push", "-q", &url, "d"]);
    let refused = |who: &Sandbox, args: &[&str], why: &str| {
        let (done, _, err) = who.knotless(args, b"");
        assert!(!done && err.contains(why), "{args:?}: {err}");
    };
    let (me, them) = (&contrib.account.handle, &reviewer.account.handle);
    let opening = [
        "pull",
        "create",
        &url,
        "main",
        &url,
        "d",
        "D",
        "--reviewer",
        me,
    ];
    refused(&contrib, &opening, "reviews no pull of their own");
    refused(
        &contrib,
        &["issue", "create", &url, "I", "--draft"],
        "unexpected --draft",
    );

    // Opened as a draft, asking for a review, it neither merges nor
    // queues, and says so.
    let opening = [
        "pull",
        "create",
        &url,
        "main",
        &url,
        "d",
        "D",
        "--draft",
        "--reviewer",
        them,
    ];
    let pull = ok(&contrib, &opening);
    listed(&owner, &url, &pull);
    let shown = ok(&owner, &["pull", "show", &url, &pull]);
    assert!(shown.contains("\ndraft\nreview requested: "), "{shown}");
    refused(
        &owner,
        &["pull", "merge", &url, &pull, "--force"],
        "is a draft",
    );
    let checked = ok(&owner, &["pull", "merge", &url, &pull, "--check"]);
    assert!(checked.starts_with("draft\t"), "{checked}");
    assert!(checked.contains("--ready`"), "{checked}");
    ok(&owner, &["runner", "add", &url, me]);
    refused(
        &owner,
        &["pull", "merge", &url, &pull, "--queue"],
        "is a draft",
    );

    // The list filters on both.
    let found = |who: &Sandbox, q: &str| ok(who, &["pull", "list", &url, "-q", q]).contains(&pull);
    assert!(found(&owner, "is:draft") && !found(&owner, "-is:draft"));
    assert!(found(&reviewer, "review-requested:@me"));
    assert!(!found(&owner, "review-requested:@me"));
    assert!(found(&owner, &format!("review-requested:{them}")));
    let did = &reviewer.account.did;
    assert!(found(&owner, &format!("review-requested:{did}")));
    assert!(!found(&owner, "review-requested:nobody.invalid"));
    // Asked with no one logged in, `@me` is no one.
    let anyone = |q: &str| -> Value {
        let repo = format!("at://{}/dev.knotless.repo/drafts", owner.account.handle);
        let at = reqwest::Url::parse_with_params(
            &format!("{}/xrpc/dev.knotless.listItems", av.base),
            [("repo", repo.as_str()), ("kind", "pull"), ("q", q)],
        );
        reqwest::blocking::get(at.unwrap()).unwrap().json().unwrap()
    };
    assert_eq!(
        anyone("review-requested:@me")["items"],
        serde_json::json!([])
    );
    let item = &anyone("is:draft")["items"][0];
    assert_eq!(item["draft"], true);
    assert_eq!(item["reviewers"][0]["did"], did.as_str());

    // Its reviewer hears of it once it is ready, not before.
    let heard = || reviewer.cli(&["notification", "list", "--peek"], b"").1;
    assert!(!heard().contains("review-requested"));
    assert_eq!(
        ok(&contrib, &["pull", "edit", &url, &pull, "--ready"]),
        "edited"
    );
    wait_until(|| heard().contains("review-requested"));
    wait_until(|| !found(&owner, "is:draft"));

    // A draft again, asking someone else instead.
    let owner_handle = owner.account.handle.as_str();
    // Asking again asks once.
    let editing = [
        "pull",
        "edit",
        &url,
        &pull,
        "--draft",
        "--reviewer",
        owner_handle,
        "--reviewer",
        owner_handle,
        "--no-reviewer",
        them,
    ];
    ok(&contrib, &editing);
    wait_until(|| found(&owner, "review-requested:@me") && found(&owner, "is:draft"));
    assert!(!found(&reviewer, "review-requested:@me"));
    let asked = ok(&owner, &["pull", "show", &url, &pull]);
    let asked = asked.lines().find(|l| l.starts_with("review requested: "));
    assert_eq!(asked.map(|l| l.matches(',').count()), Some(0), "{asked:?}");

    // Only a pull's author changes it, and only a pull is a draft.
    refused(
        &owner,
        &["pull", "edit", &url, &pull, "--ready"],
        "only the author",
    );
    let issue = ok(&contrib, &["issue", "create", &url, "An issue"]);
    refused(
        &contrib,
        &["issue", "edit", &url, &issue, "--draft"],
        "is not a pull",
    );
    refused(
        &contrib,
        &["pull", "edit", &url, &pull],
        "nothing to change",
    );
}

#[test]
fn queue_only_branches_take_pulls_through_the_queue() {
    let av = appview();
    let owner = person(account(), &av);
    let ci = person(account(), &av);
    let url = owner.url("lined");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let pull = |branch: &str| {
        owner.git(&work, &["checkout", "-q", "-B", branch, "main"]);
        owner.commit_file(&work, branch, "x\n");
        owner.git(&work, &["push", "-q", &url, branch]);
        let pull = ok(
            &owner,
            &["pull", "create", &url, "main", &url, branch, branch],
        );
        listed(&owner, &url, &pull);
        pull
    };
    let x = pull("x");

    // Set, it is one of the rule's, and read back.
    assert_eq!(ok(&owner, &["branch", "rules", &url]), "");
    fails(
        &owner,
        &["branch", "protect", &url, "main", "--queue", "--undo"],
    );
    let protected = ok(&owner, &["branch", "protect", &url, "main", "--queue"]);
    assert_eq!(protected, "main\tlinear\tqueue");
    assert_eq!(
        ok(&owner, &["branch", "rules", &url]),
        "main\tlinear\tqueue"
    );
    let rules = owner.record("dev.knotless.repo", "lined")["rules"].clone();
    assert_eq!(
        rules,
        serde_json::json!([{"branch": "main", "history": "linear", "queue": true}])
    );

    // With no runner to land the queue, a merge says so.
    let (done, _, err) = owner.knotless(&["pull", "merge", &url, &x], b"");
    assert!(
        !done && err.contains("names no runners to land it"),
        "{err}"
    );
    ok(&owner, &["runner", "add", &url, &ci.account.handle]);
    let checked = ok(&owner, &["pull", "merge", &url, &x, "--check"]);
    assert!(checked.contains("\nqueue only"), "{checked}");

    // A plain merge queues it instead, and says so.
    let queued = ok(&owner, &["pull", "merge", &url, &x]);
    assert!(
        queued.starts_with("main takes pulls through the merge queue: queued, 1 of 1\n"),
        "{queued}"
    );
    wait_until(|| ok(&owner, &["queue", "list", &url]).contains(&x));

    // The owner's --force merges it at once.
    let merged = ok(&owner, &["pull", "merge", &url, &x, "--force"]);
    assert!(merged.ends_with("deleted x"), "{merged}");
    listed(&owner, &url, &format!("pull\tmerged\t{x}"));

    // Protected again without it, a merge merges, as before.
    assert_eq!(
        ok(&owner, &["branch", "protect", &url, "main"]),
        "main\tlinear"
    );
    let y = pull("y");
    assert!(!ok(&owner, &["pull", "merge", &url, &y, "--check"]).contains("queue only"));
    let merged = ok(&owner, &["pull", "merge", &url, &y]);
    assert!(merged.ends_with("deleted y"), "{merged}");
}

#[test]
fn invites_reach_their_runner() {
    let av = appview_following(false, "");
    let owner = person(account(), &av);
    let runner = person(account(), &av);
    let (o, r) = (ctx(&owner), ctx(&runner));
    let me = |c: &knotless::env::Ctx| av.rt.block_on(knotless::session::me(c)).unwrap();
    let (mine, its) = (me(&o), me(&r));
    let invite = |name: &str| {
        let sent = knotless::invites::invite(&o, &mine, &runner.account.did, name);
        av.rt.block_on(sent).unwrap();
    };
    let list = |cursor: Option<&str>| {
        let page = av.rt.block_on(knotless::invites::list(&r, &its, cursor));
        let page = page.unwrap();
        let names: Vec<String> = page.invites.iter().map(|i| i.name.clone()).collect();
        assert!(page.invites.iter().all(|i| i.did == owner.account.did));
        (names, page.cursor.unwrap())
    };
    invite("a");
    invite("b");
    // Invited again, it moves to the end.
    invite("a");
    let (names, cursor) = list(None);
    assert_eq!(names, ["b", "a"]);
    // From where it ended, nothing more until another, and the cursor
    // stays.
    assert_eq!(list(Some(&cursor)), (vec![], cursor.clone()));
    invite("c");
    assert_eq!(list(Some(&cursor)).0, ["c"]);
    // The owner has none of its own.
    let theirs = av.rt.block_on(knotless::invites::list(&o, &mine, None));
    assert!(theirs.unwrap().invites.is_empty());

    // Each asks with a token for its own method, and for a runner by DID,
    // a repository by a name one could have.
    let client = reqwest::blocking::Client::new();
    let token = |c: &knotless::env::Ctx, who: &knotless::session::Session, lxm: &str| {
        let token = knotless::auth::service_token(c, who, &av.did, lxm);
        format!("Bearer {}", av.rt.block_on(token).unwrap())
    };
    let inviting = token(&o, &mine, knotless::invites::INVITE);
    let post = |body: Value| {
        let url = format!("{}/xrpc/{}", av.base, knotless::invites::INVITE);
        let req = client.post(url).header("authorization", &inviting);
        req.json(&body).send().unwrap().status().as_u16()
    };
    let to = runner.account.did.as_str();
    assert_eq!(
        post(serde_json::json!({"runner": "someone", "name": "a"})),
        400
    );
    assert_eq!(post(serde_json::json!({"runner": to, "name": "a/b"})), 400);
    let listing = token(&r, &its, knotless::invites::LIST);
    let get = |query: &str, auth: &str| {
        let url = format!("{}/xrpc/{}{query}", av.base, knotless::invites::LIST);
        let req = client.get(url).header("authorization", auth);
        req.send().unwrap().status().as_u16()
    };
    assert_eq!(get("?cursor=x", &listing), 400);
    assert_eq!(get("?limit=1", &inviting), 401);
}

#[test]
fn secrets_reach_only_their_runner() {
    use knotless::secrets::{self, Secret};
    let av = appview_following(false, "");
    let owner = person(account(), &av);
    let runner = person(account(), &av);
    let stranger = person(account(), &av);
    let (o, r, x) = (ctx(&owner), ctx(&runner), ctx(&stranger));
    let me = |c: &knotless::env::Ctx| av.rt.block_on(knotless::session::me(c)).unwrap();
    let (mine, its, theirs) = (me(&o), me(&r), me(&x));
    let (od, rd) = (owner.account.did.clone(), runner.account.did.clone());
    let xd = stranger.account.did.clone();
    let secret = |name: &str, runner: &str, refs: &[&str], sealed: &[u8]| Secret {
        name: name.into(),
        runner: runner.into(),
        refs: refs.iter().map(|r| r.to_string()).collect(),
        key: "did:key:zDnaeTest".into(),
        sealed: Some(sealed.to_vec()),
        updated_at: String::new(),
    };
    let put = |s: Secret| {
        av.rt
            .block_on(secrets::put(&o, &mine, &od, "3kabc", &s))
            .unwrap()
    };
    put(secret("TOKEN", &rd, &[], b"old"));
    // Put again under the same name and runner, it is replaced.
    put(secret("TOKEN", &rd, &["refs/heads/main"], b"sealed"));
    put(secret(
        "TOKEN",
        "did:web:elsewhere.example",
        &[],
        b"elsewhere",
    ));
    put(secret("DEPLOY", &rd, &[], b"deploy"));
    let list = |c, who, owner: &str| {
        let listed = av.rt.block_on(secrets::list(c, who, owner, "3kabc"));
        listed.unwrap()
    };

    // Its owner sees every one, by name, and never a value.
    let all = list(&o, &mine, &od);
    let names: Vec<_> = all
        .iter()
        .map(|s| (s.name.as_str(), s.runner.as_str()))
        .collect();
    assert_eq!(
        names,
        [
            ("DEPLOY", rd.as_str()),
            ("TOKEN", rd.as_str()),
            ("TOKEN", "did:web:elsewhere.example")
        ]
    );
    assert!(all.iter().all(|s| s.sealed.is_none()));
    // A DID of the PLC sorts before one of the web.
    assert_eq!(all[1].refs, ["refs/heads/main"]);
    assert!(all[1].updated_at.ends_with('Z'), "{all:?}");
    // The runner sees those sealed to it, with their values; anyone else,
    // none.
    let own = list(&r, &its, &od);
    let sealed: Vec<_> = own.iter().map(|s| s.sealed.clone().unwrap()).collect();
    assert_eq!(sealed, [b"deploy".to_vec(), b"sealed".to_vec()]);
    assert_eq!(own[1].key, "did:key:zDnaeTest");
    assert!(list(&x, &theirs, &od).is_empty());
    // A runner that logs in as the owner sees the values sealed to it, and
    // still no other.
    put(secret("SELF", &od, &[], b"self"));
    let all = list(&o, &mine, &od);
    let values: Vec<_> = all
        .iter()
        .map(|s| (s.name.as_str(), s.sealed.clone()))
        .collect();
    assert_eq!(
        values,
        [
            ("DEPLOY", None),
            ("SELF", Some(b"self".to_vec())),
            ("TOKEN", None),
            ("TOKEN", None)
        ]
    );
    let self_gone = secrets::delete(&o, &mine, &od, "3kabc", Some("SELF"), None);
    assert_eq!(av.rt.block_on(self_gone).unwrap(), 1);

    // Deleted by name, for every runner; by runner; then the rest.
    let delete = |name: Option<&str>, runner: Option<&str>| {
        let done = secrets::delete(&o, &mine, &od, "3kabc", name, runner);
        av.rt.block_on(done).unwrap()
    };
    assert_eq!(delete(Some("TOKEN"), Some("did:web:elsewhere.example")), 1);
    assert_eq!(delete(Some("TOKEN"), None), 1);
    put(secret("TOKEN", &rd, &[], b"again"));
    assert_eq!(delete(None, Some(&rd)), 2);
    put(secret("TOKEN", &rd, &[], b"again"));
    assert_eq!(delete(None, None), 1);
    assert!(list(&o, &mine, &od).is_empty());
    // A stranger's delete deletes only its own, which it has none of.
    let theirs_gone = secrets::delete(&x, &theirs, &xd, "3kabc", None, None);
    assert_eq!(av.rt.block_on(theirs_gone).unwrap(), 0);
    // Naming the owner, it is refused: the owner is no organization it
    // administers.
    let not_theirs = secrets::delete(&x, &theirs, &od, "3kabc", None, None);
    let refused = av.rt.block_on(not_theirs).unwrap_err().to_string();
    assert!(refused.contains("is not an admin of"), "{refused}");
    let other = secret("T", &rd, &[], b"s");
    let not_put = secrets::put(&x, &theirs, &od, "3kabc", &other);
    assert!(av.rt.block_on(not_put).is_err());

    // What a put must be, each refused.
    let client = reqwest::blocking::Client::new();
    let token = |lxm: &str| {
        let token = knotless::auth::service_token(&o, &mine, &av.did, lxm);
        format!("Bearer {}", av.rt.block_on(token).unwrap())
    };
    let putting = token(secrets::PUT);
    let post = |body: Value| {
        let url = format!("{}/xrpc/{}", av.base, secrets::PUT);
        let req = client.post(url).header("authorization", &putting);
        req.json(&body).send().unwrap().status().as_u16()
    };
    let good = serde_json::json!({
        "repo": "3kabc", "runner": rd, "name": "TOKEN",
        "key": "did:key:zDnaeTest", "sealed": {"$bytes": "c2VhbGVk"},
    });
    let with = |field: &str, value: Value| {
        let mut body = good.clone();
        body[field] = value;
        body
    };
    let many: Vec<Value> = (0..17)
        .map(|_| serde_json::json!({"pattern": "refs/heads/x"}))
        .collect();
    let big = "A".repeat(90_000);
    for bad in [
        with("repo", "a/b".into()),
        with("runner", "someone".into()),
        with("key", "did:plc:notakey".into()),
        with("name", "KNOTLESS_RUN_X".into()),
        with("refs", many.into()),
        with("refs", serde_json::json!([{"pattern": ""}])),
        with("sealed", serde_json::json!({"$bytes": ""})),
        with("sealed", serde_json::json!({"$bytes": big})),
    ] {
        assert_eq!(post(bad.clone()), 400, "{bad}");
    }
    assert_eq!(post(good), 200);
    // Each method takes a token for itself.
    let url = format!("{}/xrpc/{}?owner={od}&repo=3kabc", av.base, secrets::LIST);
    let listed = client
        .get(url)
        .header("authorization", &putting)
        .send()
        .unwrap();
    assert_eq!(listed.status().as_u16(), 401);
}

#[test]
fn owners_seal_secrets_to_their_runners() {
    use knotless::sealed::{Bound, KEY_NSID, KEY_RKEY, RunnerKeys};
    let av = appview_following(false, "");
    let owner = person(account(), &av);
    let runner = person(account(), &av);
    let late = person(account(), &av);
    let (od, rd) = (owner.account.did.clone(), runner.account.did.clone());
    let url = owner.url("sealed");
    ok(&owner, &["repo", "create", &url]);
    let set = |args: &[&str], value: &[u8]| {
        let all = [&["secret", "set", &url][..], args].concat();
        owner.cli(&all, value)
    };

    // Naming no runner, there is no one to seal it to.
    assert_eq!(set(&["TOKEN"], b"s3cret\n").0, 1);
    // Nor to a runner that publishes no key.
    ok(&owner, &["runner", "add", &url, &runner.account.handle]);
    assert_eq!(set(&["TOKEN"], b"s3cret\n").0, 1);
    // Nor to one whose key is no did:key.
    let bad = serde_json::json!({"$type": KEY_NSID, "key": "nope", "createdAt": "2026-10-29T00:00:00.000Z"});
    runner.put_record(KEY_NSID, KEY_RKEY, bad);
    assert_eq!(set(&["TOKEN"], b"s3cret\n").0, 1);
    let dir = tempfile::tempdir().unwrap();
    let keys = RunnerKeys::open_or_make(&dir.path().join("key.json")).unwrap();
    let published = serde_json::json!({
        "$type": KEY_NSID,
        "key": keys.public().did_key(),
        "createdAt": "2026-10-29T00:00:00.000Z",
    });
    runner.put_record(KEY_NSID, KEY_RKEY, published);

    let sealed = format!("sealed TOKEN to {rd}");
    assert_eq!(
        set(&["TOKEN", "--ref", "refs/heads/main"], b"s3cret\r\n"),
        (0, sealed)
    );
    assert_eq!(set(&["DEPLOY"], b"d").0, 0);
    let listed = ok(&owner, &["secret", "list", &url]);
    let lines: Vec<Vec<&str>> = listed.lines().map(|l| l.split('\t').collect()).collect();
    assert_eq!(lines[0][..3], ["DEPLOY", rd.as_str(), "*"], "{listed}");
    assert_eq!(lines[1][..3], ["TOKEN", rd.as_str(), "refs/heads/main"]);
    // Only the runner opens it, and without the line ending stdin gave it.
    let key = owner.record("dev.knotless.repo", "sealed")["key"]
        .as_str()
        .unwrap()
        .to_string();
    let r = ctx(&runner);
    let its = av.rt.block_on(knotless::session::me(&r)).unwrap();
    let left = knotless::secrets::list(&r, &its, &od, &key);
    let left = av.rt.block_on(left).unwrap();
    let token = left.iter().find(|s| s.name == "TOKEN").unwrap();
    let bound = Bound {
        owner: &od,
        repo: &key,
        runner: &rd,
        name: "TOKEN",
        refs: &token.refs,
    };
    let opened = keys.open(&token.key, &bound, token.sealed.as_deref().unwrap());
    assert_eq!(opened.unwrap(), b"s3cret");

    // A runner named later has none of those set before, and the owner is
    // told so; one leaving takes its copies with it.
    let added = ok(&owner, &["runner", "add", &url, &late.account.handle]);
    assert!(
        added.ends_with("gets the secrets set before only once each is set again: DEPLOY, TOKEN"),
        "{added}"
    );
    // Given to some of its runners alone, it is sealed to those, and a copy
    // another had is taken back; one not among them is refused.
    let theirs = RunnerKeys::open_or_make(&dir.path().join("late.json")).unwrap();
    let published = serde_json::json!({
        "$type": KEY_NSID,
        "key": theirs.public().did_key(),
        "createdAt": "2026-10-29T00:00:00.000Z",
    });
    late.put_record(KEY_NSID, KEY_RKEY, published);
    assert_eq!(set(&["SHARED"], b"s").0, 0);
    let copies = || {
        let listed = ok(&owner, &["secret", "list", &url]);
        let shared = listed.lines().filter(|l| l.starts_with("SHARED\t"));
        shared.count()
    };
    assert_eq!(copies(), 2);
    let only = ["SHARED", "--runner", runner.account.handle.as_str()];
    assert_eq!(set(&only, b"s"), (0, format!("sealed SHARED to {rd}")));
    assert_eq!(copies(), 1);
    let stranger = ["SHARED", "--runner", owner.account.handle.as_str()];
    assert_eq!(set(&stranger, b"s").0, 1);
    assert_eq!(set(&["SHARED", "--runner"], b"s").0, 1);
    ok(&owner, &["secret", "remove", &url, "SHARED"]);
    ok(&owner, &["runner", "remove", &url, &late.account.handle]);
    assert_eq!(
        ok(&owner, &["secret", "remove", &url, "DEPLOY"]),
        "removed DEPLOY"
    );
    fails(&owner, &["secret", "remove", &url, "DEPLOY"]);
    ok(&owner, &["runner", "remove", &url, &runner.account.handle]);
    assert_eq!(ok(&owner, &["secret", "list", &url]), "");

    // What a secret must be, refused before anything is sealed.
    let many: Vec<&str> = (0..17).flat_map(|_| ["--ref", "refs/heads/x"]).collect();
    for (args, value) in [
        (vec!["KNOTLESS_RUN_X"], &b"v"[..]),
        (vec!["TOKEN", "--nope"], &b"v"[..]),
        ([&["TOKEN"][..], &many].concat(), &b"v"[..]),
        (vec!["TOKEN"], &[b'x'; 20_000][..]),
    ] {
        assert_eq!(set(&args, value).0, 1, "{args:?}");
    }
    fails(&owner, &["secret"]);
    fails(&owner, &["secret", "list", &owner.url("missing")]);
    fails(&owner, &["secret", "show", &url]);
    // With no appview, there is nowhere to leave one.
    assert_eq!(Sandbox::new().cli(&["secret", "list", &url], b"").0, 1);
}

#[test]
fn accounts_away_and_gone() {
    let av = appview();
    let leaving = person(account(), &av);
    let staying = person(account(), &av);
    let did = leaving.account.did.clone();
    let index = av.app.index.clone().unwrap();
    let client = reqwest::blocking::Client::new();
    // A method that takes no input refuses a body, even an empty one.
    let pds = |method: &str, auth: &str, body: Option<Value>| {
        let url = format!("{}/xrpc/{method}", net().pds);
        let mut req = client.post(url).header("authorization", auth);
        if let Some(body) = &body {
            req = req.json(body);
        }
        let res = req.send().unwrap();
        let status = res.status();
        assert!(status.is_success(), "{method}: {}", res.text().unwrap());
    };
    let session: Value = client
        .post(format!(
            "{}/xrpc/com.atproto.server.createSession",
            net().pds
        ))
        .json(&serde_json::json!({"identifier": did, "password": leaving.account.password}))
        .send()
        .and_then(|r| r.json())
        .unwrap();
    let bearer = format!("Bearer {}", session["accessJwt"].as_str().unwrap());

    // A repository of its own, read here, and an issue on another's.
    let url = leaving.url("leaving");
    leaving.git(&leaving.work("w", 1), &["push", "-q", &url, "main"]);
    let elsewhere = staying.url("staying");
    staying.git(&staying.work("w", 1), &["push", "-q", &elsewhere, "main"]);
    let issue = ok(&leaving, &["issue", "create", &elsewhere, "Before I go"]);
    let theirs = ok(&staying, &["issue", "create", &url, "Where are you going"]);
    let key_of = |uri: &str| {
        wait_until(|| index.repo(uri).unwrap().is_some());
        let repo = index.repo(uri).unwrap().unwrap();
        repo["key"].as_str().unwrap().to_string()
    };
    let repo = format!("at://{did}/dev.knotless.repo/leaving");
    let key = key_of(&repo);
    let other = &staying.account.did;
    let other_key = key_of(&format!("at://{other}/dev.knotless.repo/staying"));
    let issues = |owner: &str, key: &str| -> Vec<String> {
        let found = index.forge(owner, key).unwrap();
        found.into_iter().map(|e| e.uri).collect()
    };
    wait_until(|| issues(other, &other_key) == [issue.clone()]);
    wait_until(|| issues(&did, &key) == [theirs.clone()]);
    let refs = format!("{}/api/{did}/leaving/refs", av.base);
    let read = || reqwest::blocking::get(&refs).unwrap().status().as_u16();
    assert_eq!(read(), 200);
    let cache = &av.app.ctx.env.cache_dir;
    let mirrors = cache.join("mirrors").join(did.replace(':', "_"));
    assert!(mirrors.exists());

    // Deactivated, it is hidden, and back when it is active again.
    let none = serde_json::json!({});
    pds("com.atproto.server.deactivateAccount", &bearer, Some(none));
    wait_until(|| index.status(&did).unwrap().as_deref() == Some("deactivated"));
    assert!(issues(other, &other_key).is_empty());
    assert_ne!(read(), 200);
    pds("com.atproto.server.activateAccount", &bearer, None);
    wait_until(|| index.status(&did).unwrap().is_none());
    assert_eq!(issues(other, &other_key), std::slice::from_ref(&issue));

    // Said to be deleted by a host that still has it, it stays; one whose
    // identity cannot be found is gone at its word.
    let said = |did: &str| knotless_serve::firehose::Event {
        did: Some(did.into()),
        account: Some("deleted".into()),
        ..Default::default()
    };
    av.rt.block_on(async { av.app.observe(said(&did)) });
    av.rt
        .block_on(async { av.app.observe(said("did:plc:neverwas")) });
    std::thread::sleep(Duration::from_secs(1));
    assert!(index.repo(&repo).unwrap().is_some());

    // Deleted, everything indexed from it goes, and its mirrors; what
    // others wrote about it stays theirs.
    let admin = format!("Basic {}", admin());
    let deleted = serde_json::json!({"did": did});
    pds("com.atproto.admin.deleteAccount", &admin, Some(deleted));
    wait_until(|| index.repo(&repo).unwrap().is_none());
    assert!(issues(other, &other_key).is_empty());
    assert_eq!(issues(&did, &key), [theirs]);
    wait_until(|| !mirrors.exists());
}

/// A repository deleted takes everything of it with it: the records keyed
/// by it and what its owner wrote about it, from the owner's account, and
/// what the appview kept of it, its mirror among them. What others wrote
/// stays in their accounts. One renamed keeps all it had.
#[test]
fn a_deleted_repository_leaves_nothing() {
    let av = appview();
    let owner = person(account(), &av);
    let other = person(account(), &av);
    let did = owner.account.did.clone();
    let index = av.app.index.clone().unwrap();
    let url = owner.url("gone");
    let work = owner.work("w", 1);
    owner.git(&work, &["push", "-q", &url, "main"]);
    let kept = owner.url("kept");
    owner.git(&work, &["push", "-q", &kept, "main"]);
    // What the owner wrote of it, and what another account did.
    let mine = ok(&owner, &["issue", "create", &url, "Mine"]);
    ok(&owner, &["comment", "add", &url, &mine, "A note"]);
    ok(&owner, &["pull", "react", &url, &mine, "heart"]);
    ok(&owner, &["repo", "star", &url]);
    ok(&owner, &["runner", "add", &url, &owner.account.handle]);
    ok(&owner, &["run", "rerun", &url, "--ref", "refs/heads/main"]);
    let file = owner.path("notes.txt");
    std::fs::write(&file, "notes\n").unwrap();
    let file = file.to_string_lossy().to_string();
    ok(
        &owner,
        &["release", "publish", &url, "notes", "0.1.0", &file],
    );
    let theirs = ok(&other, &["issue", "create", &url, "Theirs"]);
    ok(&other, &["comment", "add", &url, &theirs, "Their note"]);
    let key_of = |name: &str| {
        let uri = format!("at://{did}/dev.knotless.repo/{name}");
        wait_until(|| index.repo(&uri).unwrap().is_some());
        index.repo(&uri).unwrap().unwrap()["key"]
            .as_str()
            .unwrap()
            .to_string()
    };
    let (key, kept_key) = (key_of("gone"), key_of("kept"));
    let said = |key: &str| index.forge(&did, key).unwrap().len();
    wait_until(|| said(&key) == 4);
    ok(&other, &["issue", "create", &kept, "On the kept one"]);
    wait_until(|| said(&kept_key) == 1);
    // Read through the appview, so that it mirrors it.
    let refs = format!("{}/api/{did}/gone/refs", av.base);
    assert_eq!(reqwest::blocking::get(&refs).unwrap().status(), 200);
    let mirror = av.app.ctx.env.cache_dir.join("mirrors");
    let mirror = mirror
        .join(did.replace(':', "_"))
        .join(format!("{key}.git"));
    assert!(mirror.exists());

    // Renamed, it is the same repository, and keeps what it had.
    ok(&owner, &["repo", "rename", &kept, "still"]);
    wait_until(|| {
        index
            .repo(&format!("at://{did}/dev.knotless.repo/still"))
            .unwrap()
            .is_some()
    });
    std::thread::sleep(Duration::from_secs(1));
    assert_eq!(said(&kept_key), 1);

    // Deleted, nothing in the owner's account names it any more.
    ok(&owner, &["repo", "delete", &url, "--yes"]);
    let keyed = |collection: &str| {
        let all = owner.records(collection).into_iter();
        all.filter(|(rkey, _)| rkey.starts_with(&key)).count()
    };
    for collection in ["ref", "claim", "pack", "release", "rerun"] {
        assert_eq!(
            keyed(&format!("dev.knotless.{collection}")),
            0,
            "{collection}"
        );
    }
    for collection in ["issue", "comment", "reaction", "star"] {
        let left = owner.records(&format!("dev.knotless.{collection}"));
        assert!(left.is_empty(), "{collection}: {left:?}");
    }
    assert_eq!(other.records("dev.knotless.issue").len(), 2);
    // The appview keeps nothing of it, what others wrote on it included,
    // nor its mirror; the other repository keeps its own.
    wait_until(|| said(&key) == 0 && !mirror.exists());
    assert_eq!(said(&kept_key), 1);
    let recent = index.recent(50, None, None, None, None, false).unwrap();
    assert!(recent.iter().all(|(_, _, k)| k != &key));
    fails(&owner, &["repo", "refs", &url]);
}

/// The reference PDS's administrator, as dev-env makes it, for basic auth.
fn admin() -> String {
    use base64::Engine;
    base64::engine::general_purpose::STANDARD.encode("admin:admin-pass")
}