//! 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, 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 = 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) { 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>| { 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 { 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>| { 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 Already done"#)), "{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 |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::>()[..], ] .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::() .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| { 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 { 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 = 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 = (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> = 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| { 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 { 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") }