johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! Forks, pulls, issues and reviews, with an appview that follows the
//! reference PDS's firehose.
#[path = "../../knotless/tests/common/mod.rs"]
mod common;
use common::*;
use futures::{SinkExt, StreamExt};
use knotless_serve::gateway::Gateway;
use knotless_serve::index::Index;
use knotless_serve::{App, router};
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
struct Appview {
base: String,
did: String,
app: Arc<App>,
rt: tokio::runtime::Runtime,
_proxy: TlsProxy,
_sandbox: Sandbox,
}
/// An appview that is a gateway too, following the PDS.
fn appview() -> Appview {
appview_following(true, "")
}
/// An appview that is a gateway too, following the PDS or, for one whose
/// index never catches up, not, and run by the accounts `operators` names.
fn appview_following(firehose: bool, operators: &str) -> Appview {
let mut s = Sandbox::new();
s.set("KNOTLESS_PASSWORD", "");
s.set("KNOTLESS_DID_WEB_SCHEME", "http");
s.set("KNOTLESS_ADMIN_DIDS", operators);
let rt = tokio::runtime::Runtime::new().unwrap();
let listener = rt
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
.unwrap();
let port = listener.local_addr().unwrap().port();
let base = format!("http://localhost:{port}");
let mut gw = Gateway::new(&base).unwrap();
// Authorization servers reach a gateway, an OAuth client, over https.
let proxy = tls_proxy(port);
gw.oauth_url = proxy.url.clone();
let did = gw.did.clone();
let index = Index::open(&s.path("index.db")).unwrap();
let app = App::build(ctx(&s), Duration::ZERO, Some(gw), Some(index));
let router = router(app.clone());
rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
if firehose {
let ws = net().pds.replace("http://", "ws://");
rt.spawn(knotless_serve::firehose::watch(app.clone(), ws));
}
Appview {
base,
did,
app,
rt,
_proxy: proxy,
_sandbox: s,
}
}
fn person(account: Account, appview: &Appview) -> Sandbox {
let mut s = Sandbox::with(account);
s.set("KNOTLESS_PASSWORD", "");
s.set("KNOTLESS_DID_WEB_SCHEME", "http");
s.set("KNOTLESS_APPVIEW", &appview.base);
s.set("KNOTLESS_BROWSER", &browser(&s.account.clone(), ""));
let password = s.account.password.clone();
let handle = s.account.handle.clone();
assert_eq!(
s.cli(&["login", &handle, "--password-stdin"], password.as_bytes())
.0,
0
);
s
}
fn ok(s: &Sandbox, args: &[&str]) -> String {
let (code, out) = s.cli(args, b"");
assert_eq!(code, 0, "{args:?}: {out}");
out
}
fn fails(s: &Sandbox, args: &[&str]) {
let (code, out) = s.cli(args, b"");
assert_eq!(code, 1, "{args:?} should fail: {out}");
}
/// Waits until `list` shows `line`.
#[track_caller]
fn listed(s: &Sandbox, url: &str, line: &str) {
wait_until(|| both(s, url).1.contains(line));
}
/// What `pull list` and `issue list` print together, and whether both
/// answered.
fn both(s: &Sandbox, url: &str) -> (bool, String) {
let (pulls, pulled) = s.cli(&["pull", "list", url], b"");
let (issues, issued) = s.cli(&["issue", "list", url], b"");
(pulls == 0 && issues == 0, format!("{pulled}{issued}"))
}
/// A stand-in for a PDS's account list, of `dids` alone, as one that
/// lists no accounts by collection: the records are read from their PDS.
fn listing(av: &Appview, dids: &[&str]) -> String {
let repos: Vec<Value> = dids.iter().map(|d| serde_json::json!({"did": d})).collect();
let page = axum::Json(serde_json::json!({ "repos": repos }));
let listed = axum::routing::get(move || async move { page });
let router = axum::Router::new().route("/xrpc/com.atproto.sync.listRepos", listed);
av.rt.block_on(async move {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
url
})
}
/// A mirror, as hubble is one, that answers `getRepo` with the CAR the
/// account's PDS has, and counts what it was asked.
fn mirror(av: &Appview) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
let asked = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let count = asked.clone();
let get_repo = axum::routing::get(
move |q: axum::extract::Query<std::collections::HashMap<String, String>>| {
count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
async move {
let did = q.get("did").cloned().unwrap_or_default();
let url = format!("{}/xrpc/com.atproto.sync.getRepo", net().pds);
let res = reqwest::Client::new().get(url).query(&[("did", did)]);
let car = res.send().await.unwrap().bytes().await.unwrap();
([("content-type", "application/vnd.ipld.car")], car.to_vec())
}
},
);
let router = axum::Router::new().route("/xrpc/com.atproto.sync.getRepo", get_repo);
let url = av.rt.block_on(async move {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
url
});
(url, asked)
}
#[test]
fn forks_pulls_reviews_and_merges() {
let av = appview();
let owner = person(account(), &av);
let contrib = person(account(), &av);
let url = owner.url("forkable");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
// Never over a repository of the contributor's by its name.
ok(&contrib, &["repo", "create", &contrib.url("taken")]);
let theirs = contrib.record("dev.knotless.repo", "taken");
fails(&contrib, &["repo", "fork", &url, "taken"]);
assert_eq!(contrib.record("dev.knotless.repo", "taken"), theirs);
// A fork in the contributor's account, with a branch to offer.
let fork = ok(&contrib, &["repo", "fork", &url]);
assert_eq!(fork, format!("knotless://{}/forkable", contrib.account.did));
let clone = contrib.path("clone");
contrib.git(contrib.dir.path(), &["clone", "-q", &fork, "clone"]);
contrib.git(&clone, &["checkout", "-q", "-b", "feature"]);
let feature = contrib.commit_file(&clone, "b", "from the fork\n");
contrib.git(&clone, &["push", "-q", "origin", "feature"]);
fails(
&contrib,
&[
"pull",
"create",
&url,
"main",
&fork,
"no-such-branch",
"nope",
],
);
let pull = ok(
&contrib,
&[
"pull", "create", &url, "main", &fork, "feature", "Add b", "-m", "please",
],
);
assert!(pull.starts_with(&format!("at://{}/dev.knotless.pull/", contrib.account.did)));
listed(&owner, &url, &format!("pull\topen\t{pull}\tAdd b"));
// Its author comments; the owner reviews.
fails(
&contrib,
&["comment", "add", &url, &pull, "mine", "--approve"],
);
ok(&contrib, &["comment", "add", &url, &pull, "please look"]);
ok(
&owner,
&[
"comment",
"add",
&url,
&pull,
"looks right to me",
"--approve",
],
);
ok(&owner, &["comment", "add", &url, &pull, "thanks"]);
fails(&owner, &["comment", "add", &url, &pull, "?", "--bogus"]);
wait_until(|| {
owner
.cli(&["pull", "show", &url, &pull], b"")
.1
.contains("thanks")
});
let shown = ok(&owner, &["pull", "show", &url, &pull]);
assert!(shown.starts_with("open pull by"), "{shown}");
assert!(
shown.contains("please") && shown.contains("[approve]: looks right"),
"{shown}"
);
let review = format!(" approve at {}\n", &feature[..12]);
assert!(
shown.contains("review: ") && shown.contains(&review),
"{shown}"
);
// The base has not moved: a merge commit all the same, naming the
// pull, on main as it was and the head.
let before = owner.git(&work, &["rev-parse", "HEAD"]);
let merged = ok(&owner, &["pull", "merge", &url, &pull]);
assert_ne!(merged, feature);
let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
assert!(
refs.contains(&format!("{merged}\trefs/heads/main")),
"{refs}"
);
owner.git(&work, &["fetch", "-q", &url, "main"]);
let made = owner.git(&work, &["log", "-1", "--format=%P%n%B", "FETCH_HEAD"]);
assert!(made.starts_with(&format!("{before} {feature}\n")), "{made}");
assert!(made.trim_end().ends_with(pull.trim()), "{made}");
listed(&owner, &url, &format!("pull\tmerged\t{pull}"));
fails(&owner, &["pull", "merge", &url, &pull]);
// Merged, it is not reopened: a revert undoes it.
let (reopened, _, err) = owner.knotless(&["pull", "reopen", &url, &pull], b"");
assert!(!reopened && err.contains("is merged"), "{err}");
// The base moved: a merge commit with both parents.
owner.git(&work, &["pull", "-q", &url, "main"]);
owner.commit_file(&work, "c", "owner's\n");
owner.git(&work, &["push", "-q", &url, "main"]);
contrib.git(&clone, &["checkout", "-q", "-b", "two", &feature]);
let two = contrib.commit_file(&clone, "d", "more\n");
contrib.git(&clone, &["push", "-q", "origin", "two"]);
let second = ok(
&contrib,
&["pull", "create", &url, "main", &fork, "two", "Add d"],
);
listed(&owner, &url, &second);
let merge = ok(&owner, &["pull", "merge", &url, &second]);
let check = owner.path("check");
owner.git(owner.dir.path(), &["clone", "-q", &url, "check"]);
assert_eq!(owner.git(&check, &["rev-parse", "HEAD"]), merge);
assert_eq!(owner.git(&check, &["rev-parse", "HEAD^2"]), two);
assert!(check.join("c").exists() && check.join("d").exists());
// Both sides changed one file: a conflict, and nothing moves.
owner.git(&work, &["pull", "-q", &url, "main"]);
owner.commit_file(&work, "f", "owner's f\n");
owner.git(&work, &["push", "-q", &url, "main"]);
contrib.git(&clone, &["checkout", "-q", "-b", "clash", &two]);
contrib.commit_file(&clone, "f", "contributor's f\n");
contrib.git(&clone, &["push", "-q", "origin", "clash"]);
let clash = ok(
&contrib,
&["pull", "create", &url, "main", &fork, "clash", "Change f"],
);
listed(&owner, &url, &clash);
let (merged, _, err) = owner.knotless(&["pull", "merge", &url, &clash], b"");
assert!(!merged && err.contains("conflicts in f"), "{err}");
// Asked first, it says so, and that its author may only retarget it.
let checked = ok(&contrib, &["pull", "merge", &url, &clash, "--check"]);
let said = format!(
"conflicting\tmerge\tchecks none\nconflict\tf\nmay\tbase,head\nmerging conflicts in f; and you may not merge it: only the owner and collaborators of {url} merge its pulls"
);
assert_eq!(checked, said);
// Branches can go between opening a pull and merging it.
contrib.git(&clone, &["push", "-q", "origin", ":clash"]);
let (merged, _, err) = owner.knotless(&["pull", "merge", &url, &clash], b"");
assert!(!merged && err.ends_with("has no branch clash"), "{err}");
fails(
&contrib,
&[
"pull",
"create",
&url,
"release",
&fork,
"two",
"Into release",
],
);
owner.git(&work, &["push", "-q", &url, "main:release"]);
let release = ok(
&contrib,
&[
"pull",
"create",
&url,
"release",
&fork,
"two",
"Into release",
],
);
owner.git(&work, &["push", "-q", &url, ":release"]);
listed(&owner, &url, &release);
let (merged, _, err) = owner.knotless(&["pull", "merge", &url, &release], b"");
assert!(!merged && err.ends_with("has no branch release"), "{err}");
// A pull outlives its branches by pointing at others: the target by
// anyone who may close it, the head by its author alone.
owner.git(&work, &["push", "-q", &url, "main:stable"]);
for bad in [
vec!["pull", "retarget", &url, &release],
vec!["pull", "retarget", &url, &release, "--sideways", "x"],
vec!["pull", "retarget", &url, &release, "--base", "nope"],
vec!["pull", "retarget", &url, &release, "--head", "two"],
vec!["pull", "retarget", &url, &pull, "--base", "stable"],
] {
fails(&owner, &bad);
}
ok(
&owner,
&["pull", "retarget", &url, &release, "--base", "stable"],
);
// One whose head cannot change, as it is not the owner's pull, moves
// its base nowhere either: it still merges into stable below.
let both = [
"pull", "retarget", &url, &release, "--base", "main", "--head", "two",
];
fails(&owner, &both);
fails(
&contrib,
&["pull", "retarget", &url, &clash, "--head", "nope"],
);
ok(
&contrib,
&["pull", "retarget", &url, &clash, "--head", "two"],
);
let issue = ok(&contrib, &["issue", "create", &url, "Not a pull"]);
fails(
&owner,
&["pull", "retarget", &url, &issue, "--base", "main"],
);
// Once the index has the new target, the pull merges into it.
wait_until(|| owner.knotless(&["pull", "merge", &url, &release], b"").0);
let check = owner.path("stable");
owner.git(
owner.dir.path(),
&["clone", "-q", "-b", "stable", &url, "stable"],
);
owner.git(&check, &["merge-base", "--is-ancestor", &two, "stable"]);
// A collaborator merges, through the gateway.
ok(
&owner,
&["account", "delegate", &owner.account.handle, &av.did],
);
ok(
&owner,
&["repo", "share", &url, &contrib.account.handle, "--write"],
);
contrib.git(&clone, &["fetch", "-q", &url, "main"]);
contrib.git(&clone, &["checkout", "-q", "-b", "three", &merge]);
contrib.commit_file(&clone, "e", "collaborating\n");
contrib.git(&clone, &["push", "-q", "origin", "three"]);
let third = ok(
&contrib,
&["pull", "create", &url, "main", &fork, "three", "Add e"],
);
listed(&contrib, &url, &third);
let sha = ok(&contrib, &["pull", "merge", &url, &third]);
let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
assert!(refs.contains(&format!("{sha}\trefs/heads/main")), "{refs}");
// And makes and deletes branches the same way, as the owner does.
assert_eq!(ok(&contrib, &["branch", "create", &url, "topic"]), sha);
assert_eq!(
ok(&contrib, &["branch", "delete", &url, "topic"]),
"deleted topic"
);
assert_eq!(ok(&owner, &["branch", "create", &url, "pinned", &two]), two);
ok(&owner, &["branch", "delete", &url, "pinned"]);
// A branch in the repository itself goes with its merged pull, and
// pulls based on it move to its base, unless another open pull still
// brings it in, or it is asked to stay.
owner.git(&work, &["pull", "-q", &url, "main"]);
owner.git(&work, &["checkout", "-q", "-b", "tidy"]);
owner.commit_file(&work, "g", "tidy\n");
owner.git(&work, &["checkout", "-q", "-b", "atop"]);
owner.commit_file(&work, "h", "atop\n");
owner.git(&work, &["push", "-q", &url, "tidy", "atop", "tidy:kept"]);
let tidy = ok(
&owner,
&["pull", "create", &url, "main", &url, "tidy", "Tidy"],
);
let atop = ok(
&owner,
&["pull", "create", &url, "tidy", &url, "atop", "Atop"],
);
let kept = ok(
&owner,
&["pull", "create", &url, "main", &url, "kept", "Kept"],
);
listed(&owner, &url, &atop);
listed(&owner, &url, &kept);
let merged = ok(&owner, &["pull", "merge", &url, &tidy]);
assert!(merged.ends_with("\ndeleted tidy"), "{merged}");
// Its base gone, it merges only for having moved to main.
let merged = ok(&owner, &["pull", "merge", &url, &atop]);
assert!(merged.ends_with("\ndeleted atop"), "{merged}");
owner.git(&work, &["fetch", "-q", &url, "main"]);
owner.git(&work, &["cat-file", "-e", "FETCH_HEAD:h"]);
fails(&owner, &["pull", "merge", &url, &kept, "--bogus"]);
let merged = ok(&owner, &["pull", "merge", &url, &kept, "--keep-branch"]);
assert!(!merged.contains("deleted"), "{merged}");
let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
assert!(refs.contains("refs/heads/kept"), "{refs}");
assert!(
!refs.contains("refs/heads/tidy") && !refs.contains("refs/heads/atop"),
"{refs}"
);
// A protected head branch is not deleted, as a gateway would refuse it.
owner.git(&work, &["push", "-q", &url, "atop:guarded"]);
ok(&owner, &["branch", "protect", &url, "guarded"]);
let guarded = ok(
&owner,
&["pull", "create", &url, "main", &url, "guarded", "Guarded"],
);
listed(&owner, &url, &guarded);
let merged = ok(&owner, &["pull", "merge", &url, &guarded]);
assert!(!merged.contains("deleted"), "{merged}");
let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
assert!(refs.contains("refs/heads/guarded"), "{refs}");
// On a linear branch, squashing the pull below leaves the one above
// holding its original commits, which stay out of what it changes.
owner.git(&work, &["pull", "-q", &url, "main"]);
owner.git(&work, &["checkout", "-q", "-b", "trunk"]);
owner.git(&work, &["checkout", "-q", "-b", "low"]);
owner.commit_file(&work, "low", "low\n");
owner.git(&work, &["checkout", "-q", "-b", "mid"]);
owner.commit_file(&work, "mid", "mid\n");
owner.git(&work, &["checkout", "-q", "-b", "top"]);
owner.commit_file(&work, "top", "top\n");
owner.git(&work, &["push", "-q", &url, "trunk", "low", "mid", "top"]);
ok(&owner, &["branch", "protect", &url, "trunk"]);
let open =
|base: &str, head: &str| ok(&owner, &["pull", "create", &url, base, &url, head, head]);
let (low, mid, top) = (open("trunk", "low"), open("low", "mid"), open("mid", "top"));
listed(&owner, &url, &top);
let merged = ok(&owner, &["pull", "merge", &url, &low]);
assert!(merged.ends_with("\ndeleted low"), "{merged}");
let diff = ok(&owner, &["pull", "diff", &url, &mid]);
assert!(diff.contains("b/mid") && !diff.contains("b/low"), "{diff}");
// Its page shows the stack, highest first, with what has landed.
let (who, name) = url
.trim_start_matches("knotless://")
.split_once('/')
.unwrap();
let repo = format!("at://{who}/dev.knotless.repo/{name}");
let stack = || -> Value {
let at = reqwest::Url::parse_with_params(
&format!("{}/xrpc/dev.knotless.getThread", av.base),
[("repo", repo.as_str()), ("uri", mid.as_str())],
)
.unwrap();
let thread: Value = reqwest::blocking::get(at).unwrap().json().unwrap();
thread["stack"].clone()
};
wait_until(|| stack()["pulls"][2]["merged"].is_string());
let shown = stack();
assert_eq!(shown["into"], "trunk", "{shown}");
let titles: Vec<&str> = (0..3)
.map(|i| shown["pulls"][i]["item"]["title"].as_str().unwrap())
.collect();
assert_eq!(titles, ["top", "mid", "low"], "{shown}");
// The base taken into its branch: the base is where it starts again.
owner.git(&work, &["checkout", "-q", "mid"]);
owner.git(&work, &["fetch", "-q", &url, "trunk"]);
owner.git(&work, &["merge", "-q", "--no-edit", "FETCH_HEAD"]);
owner.git(&work, &["push", "-q", &url, "mid"]);
let diff = ok(&owner, &["pull", "diff", &url, &mid]);
assert!(diff.contains("b/mid") && !diff.contains("b/low"), "{diff}");
let merged = ok(&owner, &["pull", "merge", &url, &mid]);
assert!(merged.ends_with("\ndeleted mid"), "{merged}");
// Rebased, its start is no longer under it: so too.
owner.git(&work, &["fetch", "-q", &url, "trunk"]);
owner.git(
&work,
&["rebase", "-q", "--onto", "FETCH_HEAD", "mid", "top"],
);
owner.git(&work, &["push", "-q", "-f", &url, "top"]);
let diff = ok(&owner, &["pull", "diff", &url, &top]);
assert!(diff.contains("b/top") && !diff.contains("b/mid"), "{diff}");
ok(&owner, &["pull", "merge", &url, &top]);
owner.git(&work, &["fetch", "-q", &url, "trunk"]);
for file in ["low", "mid", "top"] {
let at = format!("FETCH_HEAD:{file}");
owner.git(&work, &["cat-file", "-e", &at]);
}
// Someone else's commit, elsewhere on the firehose, changes nothing.
av.app.observe(knotless_serve::firehose::Event::default());
assert_eq!(av.app.events_for("did:plc:nobody"), 0);
// Nor does a profile edited elsewhere on the network, one whose
// profile is not kept: it is not kept for it either.
av.app.observe(knotless_serve::firehose::Event {
did: Some("did:plc:elsewhere".into()),
ops: vec![knotless_serve::firehose::Op {
action: "update".into(),
collection: "app.bsky.actor.profile".into(),
rkey: "self".into(),
record: Some(serde_json::json!({"displayName": "Elsewhere"})),
}],
..Default::default()
});
assert_eq!(av.app.events_for("did:plc:elsewhere"), 0);
let kept = av.app.index.as_ref().unwrap().profile("did:plc:elsewhere");
assert_eq!(kept.unwrap(), None);
// A record gone before the index fetched it is an error to skip.
let gone = knotless_serve::firehose::Op {
action: "create".into(),
collection: "dev.knotless.issue".into(),
rkey: "3zzzzzzzzzzzz".into(),
record: None,
};
let index = av.app.index.as_ref().unwrap();
assert!(
av.rt
.block_on(knotless_serve::index_op(
&av.app.ctx,
index,
&owner.account.did,
&gone,
None
))
.is_err()
);
// The index knows the fork, and finds repositories by name: those
// named as asked first, the original before its newer fork.
let dids = |page: &Value| -> Vec<String> {
let repos = page["repos"].as_array().unwrap();
repos
.iter()
.map(|r| r["did"].as_str().unwrap().to_string())
.collect()
};
let (theirs, mine) = (owner.account.did.as_str(), contrib.account.did.as_str());
let list = |cursor: Option<&str>, limit: &str, token: Option<&str>| -> Value {
let mut query = vec![("q", "forkable"), ("limit", limit)];
query.extend(cursor.map(|c| ("cursor", c)));
let mut req = reqwest::blocking::Client::new()
.get(format!("{}/xrpc/dev.knotless.listRepos", av.base))
.query(&query);
if let Some(token) = token {
req = req.bearer_auth(token);
}
req.send().unwrap().json().unwrap()
};
let found = list(None, "50", None);
assert_eq!(dids(&found), [theirs, mine]);
assert!(found.get("cursor").is_none());
// A page at a time, more named so than a page holds: the first still
// starts with the original, and the rest come newest first, in full.
let first = list(None, "1", None);
assert_eq!(dids(&first), [theirs, mine]);
let second = list(first["cursor"].as_str(), "1", None);
assert_eq!(dids(&second), [theirs]);
assert!(dids(&list(second["cursor"].as_str(), "1", None)).is_empty());
// Asked as an account, its own come first: the fork, to its owner.
let token = |who: &Sandbox| {
av.rt.block_on(async {
let ctx = ctx(who);
let me = knotless::session::me(&ctx).await.unwrap();
let lxm = "dev.knotless.listRepos";
knotless::auth::service_token(&ctx, &me, &av.did, lxm)
.await
.unwrap()
})
};
let forker = token(&contrib);
assert_eq!(dids(&list(None, "50", Some(&forker))), [mine, theirs]);
// And all of its own fit on the first page, no page after lists them.
let original = token(&owner);
let first = list(None, "1", Some(&original));
assert_eq!(dids(&first), [theirs, mine]);
let next = list(first["cursor"].as_str(), "1", Some(&original));
assert!(dids(&next).is_empty());
// And finds their owners by handle, ignoring case, with how many each owns.
let owners = |query: &[(&str, &str)]| -> Value {
reqwest::blocking::Client::new()
.get(format!("{}/xrpc/dev.knotless.searchOwners", av.base))
.query(query)
.send()
.unwrap()
.json()
.unwrap()
};
let shouted = owner.account.handle.to_uppercase();
let found = owners(&[("q", &shouted)]);
// Another's handle may hold this one, but only this one starts with it.
assert_eq!(
found["owners"][0]["owner"]["did"],
owner.account.did.as_str()
);
assert_eq!(found["owners"][0]["repoCount"], 1);
// Every handle holds nothing, a page of them at most; none holds this.
let all = owners(&[("q", ""), ("limit", "1")]);
assert_eq!(all["owners"].as_array().unwrap().len(), 1);
let none = owners(&[("q", "no such handle")]);
assert!(none["owners"].as_array().unwrap().is_empty());
// Handles are kept in the index. One that changes is looked up again
// on the identity event saying so, and found as it is now.
let indexed = av.app.index.as_ref().unwrap();
let did = owner.account.did.as_str();
let now = indexed.handle(did).unwrap().unwrap().1;
indexed
.set_handle(did, Some("before.example"), now)
.unwrap();
let before = owners(&[("q", "before.example")]);
assert_eq!(before["owners"][0]["owner"]["handle"], "before.example");
let kept = || indexed.handle(did).unwrap().unwrap().0;
let renamed = |want: &str| wait_until(|| kept().as_deref() == Some(want));
let identity = knotless_serve::firehose::Event {
did: Some(did.into()),
identity: true,
..Default::default()
};
// Inside the runtime, which the lookup is spawned on; out of it again
// for the blocking requests.
av.rt.block_on(async { av.app.observe(identity) });
renamed(&owner.account.handle);
// One kept a day is shown as it was, and looked up behind the page.
indexed.set_handle(did, Some("stale.example"), 0).unwrap();
let stale = owners(&[("q", "stale.example")]);
assert_eq!(stale["owners"][0]["owner"]["handle"], "stale.example");
renamed(&owner.account.handle);
// Having none is kept ten minutes, for a handle whose DNS has just gone
// live: within them the DID is shown, and after them it is looked up.
let profile = || -> serde_json::Value {
reqwest::blocking::Client::new()
.get(format!("{}/xrpc/dev.knotless.getProfile", av.base))
.query(&[("actor", did)])
.send()
.unwrap()
.json()
.unwrap()
};
let seconds = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
let seconds = seconds.unwrap().as_secs() as i64;
indexed.set_handle(did, None, seconds).unwrap();
assert_eq!(profile()["handle"], did);
assert_eq!(kept(), None);
indexed.set_handle(did, None, seconds - 601).unwrap();
assert_eq!(profile()["handle"], did);
renamed(&owner.account.handle);
let uri = format!("at://{}/dev.knotless.repo/forkable", owner.account.did);
let indexed = av.app.index.as_ref().unwrap();
let key = indexed.repo(&uri).unwrap().unwrap()["key"]
.as_str()
.unwrap()
.to_string();
let forks = indexed.forks(&owner.account.did, &key);
assert_eq!(forks.unwrap().len(), 1);
// A write its event does not carry, as a commit too big to, is read
// from its PDS.
let (did, (_, _, rkey)) = (
&owner.account.did,
knotless::forge::parse_uri(&pull).unwrap(),
);
let has = || {
indexed
.forge(did, &key)
.unwrap()
.iter()
.any(|e| e.uri == pull)
};
indexed
.delete(&contrib.account.did, "dev.knotless.pull", rkey)
.unwrap();
assert!(!has());
let entered = av.rt.enter();
av.app.observe(knotless_serve::firehose::Event {
did: Some(contrib.account.did.clone()),
ops: vec![knotless_serve::firehose::Op {
action: "create".into(),
collection: "dev.knotless.pull".into(),
rkey: rkey.into(),
record: None,
}],
..Default::default()
});
drop(entered);
wait_until(has);
// A second appview, backfilled, sees the same. The PDS lists its
// accounts all at once: those of every test binary sharing it, read
// one by one, so it lists only these two here.
let s = Sandbox::new();
let index = Index::open(&s.path("i.db")).unwrap();
let pds = listing(&av, &[&owner.account.did, &contrib.account.did]);
let none = knotless_serve::Sources::default();
let n = av
.rt
.block_on(knotless_serve::backfill(&ctx(&s), &index, &pds, &none))
.unwrap();
assert!(n.records >= 10 && n.read == 2, "{n:?}");
let report = av.rt.block_on(knotless_serve::backfilled(
&ctx(&s),
&index,
"http://127.0.0.1:9",
&none,
));
assert!(
report.starts_with("backfilling from http://127.0.0.1:9: "),
"{report}"
);
// Read again with nothing new, each account is passed over.
let report = av
.rt
.block_on(knotless_serve::backfilled(&ctx(&s), &index, &pds, &none));
assert!(report.ends_with(", 2 unchanged, 0 failed"), "{report}");
let did = owner.account.did.as_str();
let backfilled = index.forge(did, &key).unwrap().len();
wait_until(|| indexed.forge(did, &key).unwrap().len() == backfilled);
// A relay names only the accounts holding knotless records, a page at
// a time, and those are what is read.
let holder = owner.account.did.clone();
let relay = av.rt.block_on(async move {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let by = axum::routing::get(
move |q: axum::extract::Query<std::collections::HashMap<String, String>>| {
let page = match q.get("cursor") {
None => serde_json::json!({
"repos": [{"did": holder}, {"did": "did:plc:nobodyhereatall"}],
"cursor": "2"
}),
Some(_) => serde_json::json!({"repos": []}),
};
async move { axum::Json(page) }
},
);
let relay = axum::Router::new().route("/xrpc/com.atproto.sync.listReposByCollection", by);
tokio::spawn(async move { axum::serve(listener, relay).await.unwrap() });
url
});
let fresh = Index::open(&s.path("relay.db")).unwrap();
assert!(fresh.rebuilt);
let filled = av
.rt
.block_on(knotless_serve::backfill(&ctx(&s), &fresh, &relay, &none))
.unwrap();
// One it cannot read is tried again later, and does not stop the rest.
assert_eq!((filled.read, filled.failed), (1, 1), "{filled:?}");
let nobody = fresh.backfilled("did:plc:nobodyhereatall").unwrap();
assert_eq!((nobody.rev, nobody.tries), (None, 1));
let again = av
.rt
.block_on(knotless_serve::backfill(&ctx(&s), &fresh, &relay, &none))
.unwrap();
assert_eq!((again.unchanged, again.failed), (2, 0), "{again:?}");
// Only the owner's: what others wrote, the relay here does not name.
assert!(fresh.repo(&uri).unwrap().is_some());
let owners = fresh.forge(did, &key).unwrap().len();
assert!(owners > 0 && owners < backfilled, "{owners}");
// Opened again, it is the same version, and kept.
assert!(!Index::open(&s.path("relay.db")).unwrap().rebuilt);
// A directory that answers is asked before the relay, one that does
// not passed by, and each repository read from the mirror.
let (mirror, asked) = mirror(&av);
let sources = knotless_serve::Sources {
directories: vec!["http://127.0.0.1:9".into(), relay.clone()],
mirror: Some(mirror),
};
let mirrored = Index::open(&s.path("mirrored.db")).unwrap();
let filled = av
.rt
.block_on(knotless_serve::backfill(
&ctx(&s),
&mirrored,
"http://127.0.0.1:9",
&sources,
))
.unwrap();
assert_eq!((filled.read, filled.failed), (1, 1), "{filled:?}");
assert_eq!(asked.load(std::sync::atomic::Ordering::Relaxed), 1);
assert_eq!(mirrored.forge(did, &key).unwrap().len(), owners);
// A mirror that cannot be reached, or does not hold the repository,
// leaves each read to its PDS.
for (i, gone) in ["http://127.0.0.1:9", relay.as_str()]
.into_iter()
.enumerate()
{
let gone = knotless_serve::Sources {
directories: vec![relay.clone()],
mirror: Some(gone.into()),
};
let unmirrored = Index::open(&s.path(&format!("unmirrored{i}.db"))).unwrap();
let filled = av
.rt
.block_on(knotless_serve::backfill(
&ctx(&s),
&unmirrored,
"http://127.0.0.1:9",
&gone,
))
.unwrap();
assert_eq!(filled.read, 1, "{filled:?}");
assert_eq!(unmirrored.forge(did, &key).unwrap().len(), owners);
}
// A sweep fills it again, now and then, with what a feed missed.
let swept = std::sync::Arc::new(Index::open(&s.path("swept.db")).unwrap());
let sweep = av.rt.spawn(knotless_serve::sweep(
ctx(&s),
swept.clone(),
relay.clone(),
none.clone(),
std::time::Duration::from_millis(50),
));
wait_until(|| swept.repo(&uri).unwrap().is_some());
sweep.abort();
// A deletion takes the record out of the index.
let pull_rkey = third.rsplit('/').next().unwrap();
let deleted = knotless_serve::firehose::Op {
action: "delete".into(),
collection: "dev.knotless.pull".into(),
rkey: pull_rkey.into(),
record: None,
};
av.rt
.block_on(knotless_serve::index_op(
&ctx(&s),
&index,
&contrib.account.did,
&deleted,
None,
))
.unwrap();
assert_eq!(index.forge(did, &key).unwrap().len(), backfilled - 1);
}
#[test]
fn merges_close_the_issues_they_name() {
let av = appview();
let owner = person(account(), &av);
let author = person(account(), &av);
let url = owner.url("closing");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let elsewhere = owner.url("elsewhere");
ok(&owner, &["repo", "create", &elsewhere]);
let open = ok(&author, &["issue", "create", &url, "It breaks"]);
let shut = ok(&author, &["issue", "create", &url, "Already done"]);
let other = ok(&author, &["issue", "create", &elsewhere, "Not here"]);
ok(&author, &["pull", "close", &url, &shut]);
listed(&owner, &url, &format!("issue\tclosed\t{shut}"));
listed(&owner, &url, &open);
// One by its page, one closed already, one on another repository.
let (did, _, rkey) = knotless::forge::parse_uri(&open).unwrap();
let handle = &owner.account.handle;
let page = format!("{}/{handle}/closing/issue/{did}/{rkey}", av.base);
let body = format!("Fixes [it]({page}), closes {shut} and resolves {other}.");
owner.git(&work, &["checkout", "-q", "-b", "fix"]);
owner.commit_file(&work, "a", "fixed\n");
owner.git(&work, &["push", "-q", &url, "fix"]);
let pull = ok(
&owner,
&[
"pull", "create", &url, "main", &url, "fix", "Fix it", "-m", &body,
],
);
listed(&owner, &url, &pull);
let data = |item: &str| -> Value {
let at = reqwest::Url::parse_with_params(
&format!("{}/xrpc/dev.knotless.getThread", av.base),
[
(
"repo",
format!("at://{handle}/dev.knotless.repo/closing").as_str(),
),
("uri", item),
],
);
reqwest::blocking::get(at.unwrap()).unwrap().json().unwrap()
};
let closes = &data(&pull)["closes"];
let titles: Vec<&str> = (closes.as_array().unwrap().iter())
.map(|i| i["title"].as_str().unwrap())
.collect();
assert_eq!(titles, ["It breaks", "Already done"]);
assert_eq!(closes[1]["state"], "closed");
// A bare URI of one of the repository's own shows as a link to it,
// titled; one of another repository's stays as it is.
let html = data(&pull)["html"].as_str().unwrap().to_string();
let (_, _, shut_key) = knotless::forge::parse_uri(&shut).unwrap();
let shut_page = format!("/{handle}/closing/issue/{did}/{shut_key}");
assert!(
html.contains(&format!(r#"closes <a href="{shut_page}">Already done</a>"#)),
"{html}"
);
assert!(html.contains(other.trim()), "{html}");
// A pull that names it with no keyword mentions it, and closes nothing.
owner.git(&work, &["checkout", "-q", "-b", "see"]);
owner.git(&work, &["push", "-q", &url, "see"]);
let body = format!("See {}", open.trim());
let see = ok(
&owner,
&[
"pull", "create", &url, "main", &url, "see", "See it", "-m", &body,
],
);
listed(&owner, &url, &see);
let mentioned = &data(&open)["mentionedBy"];
assert_eq!(mentioned[0]["item"]["title"], "Fix it");
assert_eq!(mentioned[0]["closes"], true);
assert_eq!(mentioned[1]["item"]["uri"], see.trim());
assert!(mentioned[1]["closes"].is_null());
assert_eq!(mentioned.as_array().unwrap().len(), 2);
let shown = ok(&owner, &["pull", "show", &url, &open]);
let lines = format!(
"\nmentioned in {}, to close it\nmentioned in {}",
pull.trim(),
see.trim()
);
assert!(shown.contains(&lines), "{shown}");
assert!(data(&shut)["mentionedBy"].as_array().unwrap().len() == 1);
// Merged into the default branch, it closes the open one, as the
// merger, saying which pull did.
let merged = ok(&owner, &["pull", "merge", &url, &pull]);
assert!(merged.ends_with(&format!("\nclosed {open}")), "{merged}");
listed(&owner, &url, &format!("issue\tclosed\t{open}"));
let shown = ok(&owner, &["pull", "show", &url, &open]);
assert!(shown.contains(&format!("\nclosed by {pull}")), "{shown}");
assert!(!ok(&owner, &["pull", "show", &url, &shut]).contains("closed by"));
let listing = ok(&owner, &["issue", "list", &elsewhere]);
assert!(
listing.contains(&format!("issue\topen\t{other}")),
"{listing}"
);
let issue = data(&open);
assert_eq!(issue["closedBy"]["title"], "Fix it");
assert_eq!(issue["closedBy"]["uri"], pull.trim());
assert!(data(&shut)["closedBy"].is_null());
}
#[test]
fn pushes_mark_the_pulls_they_land_merged() {
let av = appview();
let owner = person(account(), &av);
let contrib = person(account(), &av);
let url = owner.url("landed");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let start = owner.git(&work, &["rev-parse", "HEAD"]);
let issue = ok(&contrib, &["issue", "create", &url, "It breaks"]);
// A stack, a pull of its own, one already on main, one into a branch
// the push leaves alone, and one from a fork.
owner.git(&work, &["checkout", "-q", "-b", "a"]);
let a = owner.commit_file(&work, "a", "a\n");
owner.git(&work, &["checkout", "-q", "-b", "b"]);
let b = owner.commit_file(&work, "b", "b\n");
owner.git(&work, &["checkout", "-q", "-b", "c", &start]);
owner.commit_file(&work, "c", "c\n");
owner.git(&work, &["branch", "e", &start]);
owner.git(&work, &["branch", "dev", &start]);
owner.git(
&work,
&["push", "-q", &url, "a", "b", "c", "e", "dev", "c:d"],
);
let pa = ok(&owner, &["pull", "create", &url, "main", &url, "a", "A"]);
let fixes = format!("Fixes {}", issue.trim());
let pb = ok(
&owner,
&["pull", "create", &url, "main", &url, "b", "B", "-m", &fixes],
);
let pc = ok(&owner, &["pull", "create", &url, "main", &url, "c", "C"]);
let pd = ok(&owner, &["pull", "create", &url, "dev", &url, "d", "D"]);
let pe = ok(&owner, &["pull", "create", &url, "main", &url, "e", "E"]);
let fork = ok(&contrib, &["repo", "fork", &url]);
let clone = contrib.path("clone");
contrib.git(contrib.dir.path(), &["clone", "-q", &fork, "clone"]);
let f = contrib.commit_file(&clone, "f", "from the fork\n");
contrib.git(&clone, &["push", "-q", "origin", "HEAD:f"]);
let pf = ok(&contrib, &["pull", "create", &url, "main", &fork, "f", "F"]);
for pull in [&pa, &pb, &pc, &pd, &pe, &pf] {
listed(&owner, &url, pull);
}
// The stack and the fork's branch pushed to main by hand, b with them.
owner.git(&work, &["checkout", "-q", "b"]);
owner.git(
&work,
&["pull", "-q", "--no-rebase", "--no-edit", &fork, "f"],
);
let (pushed, _, said) = owner.git_raw(&work, &["push", &url, "HEAD:main", "b"]);
assert!(pushed, "{said}");
for pull in [&pa, &pb, &pf] {
let line = format!("knotless: {} merged, as main now has its head", pull.trim());
assert!(said.contains(&line), "{said}");
}
for pull in [&pa, &pb, &pf] {
listed(&owner, &url, &format!("pull\tmerged\t{}", pull.trim()));
}
listed(&owner, &url, &format!("issue\tclosed\t{}", issue.trim()));
for pull in [&pc, &pd, &pe] {
listed(&owner, &url, &format!("pull\topen\t{}", pull.trim()));
}
// One already on its base merges as it stands, moving nothing.
let tip = owner.git(&work, &["rev-parse", "HEAD"]);
let merged = ok(&owner, &["pull", "merge", &url, &pe]);
assert!(merged.starts_with(&start), "{merged}");
let (_, refs) = owner.cli(&["repo", "refs", &url], b"");
assert!(refs.contains(&format!("{tip}\trefs/heads/main")), "{refs}");
// Each commit links to the pull that brought it: the least that did.
let handle = &owner.account.handle;
let repo = format!("at://{handle}/dev.knotless.repo/landed");
let at = reqwest::Url::parse_with_params(
&format!("{}/xrpc/dev.knotless.listCommits", av.base),
[("repo", repo.as_str())],
);
let pulls = || -> Vec<(String, Value)> {
let page: Value = reqwest::blocking::get(at.clone().unwrap())
.unwrap()
.json()
.unwrap();
let commits = page["commits"].as_array().unwrap().iter();
commits
.map(|c| (c["sha"].as_str().unwrap().into(), c["pull"]["uri"].clone()))
.collect()
};
wait_until(|| {
pulls()
.iter()
.any(|(sha, pull)| *sha == f && pull == pf.trim())
});
let pulls = pulls();
let of = |sha: &str| pulls.iter().find(|(s, _)| s == sha).unwrap().1.clone();
assert_eq!(of(&a), pa.trim());
assert_eq!(of(&b), pb.trim());
assert_eq!(of(&start), Value::Null);
}
#[test]
fn merges_wait_for_their_checks() {
let av = appview();
let mut owner = person(account(), &av);
// A merge that waits reads its checks again this often.
owner.set("KNOTLESS_POLL_MS", "100");
// The runner's account, and someone who is not the owner.
let ci = person(account(), &av);
let url = owner.url("gated");
let work = owner.work("w", 1);
std::fs::create_dir_all(work.join(".knotless/workflows")).unwrap();
// One runs on pulls; the other only on pushes, which no merge waits for.
let on_pull = "on: {pull: {}}\nsteps: [{run: 'true'}]\n";
owner.commit_file(&work, ".knotless/workflows/ci.yml", on_pull);
let base = owner.commit_file(&work, ".knotless/workflows/push.yml", "steps: []\n");
owner.git(&work, &["push", "-q", &url, "main"]);
ok(&owner, &["runner", "add", &url, &ci.account.handle]);
let key = owner.record("dev.knotless.repo", "gated")["key"]
.as_str()
.unwrap()
.to_string();
let repo = serde_json::json!({
"uri": format!("at://{}/dev.knotless.repo/gated", owner.account.did),
"key": key,
});
let mut second = 0;
// The runner's check of `pull` at `head` merged with `tip`, or of the
// head alone, as before checks merged it, newer than any before.
let mut check = |pull: &str, head: &str, tip: Option<&str>, status: &str| {
second += 1;
let rkey = knotless::keys::check_rkey(&owner.account.did, &key, pull, head, tip, "ci");
let mut record = serde_json::json!({
"$type": "dev.knotless.check", "repo": repo, "ref": "refs/heads/x",
"pull": pull, "commit": head, "workflow": "ci", "status": status,
"startedAt": format!("2026-10-21T00:00:{second:02}.000Z"),
});
if let Some(tip) = tip {
record["baseCommit"] = tip.into();
}
ci.put_record("dev.knotless.check", &rkey, record);
};
let refused = |who: &Sandbox, args: &[&str], why: &str| {
let (merged, _, err) = who.knotless(args, b"");
assert!(!merged && err.contains(why), "{args:?}: {err}");
let logs = format!("`knotless run list {url} --pull ");
assert!(err.contains(&logs), "{err}");
};
// The base has not moved, so its head alone is what is checked.
owner.git(&work, &["checkout", "-q", "-b", "x"]);
let head = owner.commit_file(&work, "x", "x\n");
owner.git(&work, &["push", "-q", &url, "x"]);
let pull = ok(&owner, &["pull", "create", &url, "main", &url, "x", "X"]);
listed(&owner, &url, &pull);
refused(&owner, &["pull", "merge", &url, &pull], "none has run yet");
let checked = ok(&owner, &["pull", "merge", &url, &pull, "--check"]);
let said = "blocked\tmerge\tchecks pending\ndeletes\tx\nmay\tmerge,force,base,head,rerun\n";
assert!(checked.starts_with(said), "{checked}");
assert!(checked.contains("none has run yet"), "{checked}");
check(&pull, &base, Some(&base), "success");
refused(
&owner,
&["pull", "merge", &url, &pull],
"of an older head, ",
);
check(&pull, &head, None, "success");
refused(
&owner,
&["pull", "merge", &url, &pull],
"of main at an older tip",
);
let moved = "e".repeat(40);
check(&pull, &head, Some(&moved), "success");
refused(
&owner,
&["pull", "merge", &url, &pull],
"of main at eeeeeeeeeeee",
);
// Waiting for it, the owner asks the runners to run it.
refused(
&owner,
&["pull", "merge", &url, &pull, "--wait", "1"],
"no checks at its head",
);
let asked = owner.records("dev.knotless.rerun");
assert!(
asked.iter().any(|(_, r)| r["pull"] == pull.as_str()),
"{asked:?}"
);
check(&pull, &head, Some(&base), "running");
let checked = ok(&owner, &["pull", "merge", &url, &pull, "--check"]);
assert!(
checked.starts_with("blocked\tmerge\tchecks running\n"),
"{checked}"
);
refused(
&owner,
&["pull", "merge", &url, &pull, "--wait", "1"],
"still running: ci",
);
let (merged, _, err) = ci.knotless(&["pull", "merge", &url, &pull, "--force"], b"");
assert!(!merged && err.contains("only the owner"), "{err}");
fails(&owner, &["pull", "merge", &url, &pull, "--wait", "soon"]);
// Waiting, it reads them again until they pass: asked for them, it
// has read them once, and waits to read again however long that took.
check(&pull, &head, Some(&base), "cancelled");
let asks = owner.records("dev.knotless.rerun");
let wait = ["pull", "merge", &url, &pull, "--wait", "60"];
let tip = std::thread::scope(|scope| {
let merging = scope.spawn(|| owner.knotless(&wait, b""));
wait_until(|| owner.records("dev.knotless.rerun") != asks);
check(&pull, &head, Some(&base), "success");
let (merged, out, err) = merging.join().unwrap();
assert!(merged, "{err}");
let (made, deleted) = out.split_once('\n').unwrap();
assert!(made != head && deleted == "deleted x", "{out}");
made.to_string()
});
// Merged, it can no longer say whether it merges.
let (checked, _, err) = owner.knotless(&["pull", "merge", &url, &pull, "--check"], b"");
assert!(!checked && err.contains("is merged"), "{err}");
// The base moved: what is checked is the merge of the two.
// A workflow that does not load is waited for too, as its runner files
// an error for it; what failed is said before what has not run.
owner.git(&work, &["checkout", "-q", "-b", "y", &base]);
owner.commit_file(&work, ".knotless/workflows/bad.yml", "steps: 3\n");
let y = owner.commit_file(&work, "y", "y\n");
owner.git(&work, &["push", "-q", &url, "y"]);
let pull = ok(&owner, &["pull", "create", &url, "main", &url, "y", "Y"]);
listed(&owner, &url, &pull);
check(&pull, &y, Some(&tip), "failure");
let checked = ok(&owner, &["pull", "merge", &url, &pull, "--check"]);
assert!(
checked.starts_with("blocked\tmerge\tchecks failed\n"),
"{checked}"
);
// The workflows at main, and what runs each there: one that does not
// load is at the pull's branch only.
let listed = ok(&owner, &["workflow", "list", &url]);
assert_eq!(listed, "refs/heads/main\nci\tpull\npush\tpush,asked");
let listed = ok(&owner, &["workflow", "list", &url, "y"]);
assert!(listed.starts_with("refs/heads/y\nbad\t\t"), "{listed}");
fails(&owner, &["workflow", "list", &url, "nope"]);
refused(
&owner,
&["pull", "merge", &url, &pull],
"did not pass its checks at its head, ",
);
let (_, _, err) = owner.knotless(&["pull", "merge", &url, &pull], b"");
assert!(
err.contains(": ci (failure). Read their logs with"),
"{err}"
);
let (merged, out, err) = owner.knotless(&["pull", "merge", &url, &pull, "--force"], b"");
assert!(merged, "{err}");
assert_ne!(out.trim(), y);
}
#[test]
fn a_check_left_running_is_read_again() {
let av = appview();
let owner = person(account(), &av);
let url = owner.url("rechecked");
let work = owner.work("w", 1);
owner.commit_file(&work, "a", "a\n");
owner.git(&work, &["push", "-q", &url, "main"]);
ok(&owner, &["runner", "add", &url, &owner.account.handle]);
let key = owner.record("dev.knotless.repo", "rechecked")["key"]
.as_str()
.unwrap()
.to_string();
let uri = format!("at://{}/dev.knotless.repo/rechecked", owner.account.did);
let record = |status: &str, at: &str| {
serde_json::json!({
"$type": "dev.knotless.check", "repo": {"uri": uri, "key": key},
"ref": "refs/heads/main", "commit": "0".repeat(40), "workflow": "deploy",
"status": status, "startedAt": at,
})
};
let listed = format!(
"{}/xrpc/dev.knotless.listChecks?repo={uri}&key={key}",
av.base
);
let status = |rkey: &str| -> String {
let page: Value = reqwest::blocking::get(&listed).unwrap().json().unwrap();
let found = page["checks"]
.as_array()
.unwrap()
.iter()
.find(|c| c["uri"].as_str().unwrap().ends_with(&format!("/{rkey}")));
found.map_or(String::new(), |c| c["value"]["status"].to_string())
};
// One finished long since, and one started just now.
let long_ago = "2026-10-21T00:00:00.000Z";
owner.put_record("dev.knotless.check", "done", record("success", long_ago));
let now = knotless::records::now();
owner.put_record("dev.knotless.check", "now", record("running", &now));
wait_until(|| status("done") == "\"success\"" && status("now") == "\"running\"");
// The index missed the write that finished it, as one written while
// serve rolls over: read from its PDS again, a page later it is done.
let index = av.app.index.as_ref().unwrap();
let missed = record("running", long_ago);
index
.put(&owner.account.did, "dev.knotless.check", "done", &missed)
.unwrap();
assert_eq!(status("done"), "\"running\"");
wait_until(|| status("done") == "\"success\"");
assert_eq!(status("now"), "\"running\"");
}
#[test]
fn pulls_squash_into_a_linear_branch() {
let av = appview();
let owner = person(account(), &av);
let contrib = person(account(), &av);
let url = owner.url("squashed");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let base = owner.git(&work, &["rev-parse", "HEAD"]);
let fork = ok(&contrib, &["repo", "fork", &url]);
let clone = contrib.path("clone");
contrib.git(contrib.dir.path(), &["clone", "-q", &fork, "clone"]);
contrib.git(&clone, &["checkout", "-q", "-b", "two"]);
contrib.commit_file(&clone, "a", "one\n");
contrib.commit_file(&clone, "b", "two\n");
contrib.git(&clone, &["push", "-q", "origin", "two"]);
let pull = ok(
&contrib,
&[
"pull",
"create",
&url,
"main",
&fork,
"two",
"Add a and b",
"-m",
"both",
],
);
listed(&owner, &url, &pull);
// A fast-forward would bring both commits; protected, it is one.
ok(&owner, &["branch", "protect", &url, "main"]);
let checked = ok(&owner, &["pull", "merge", &url, &pull, "--check"]);
assert_eq!(checked, "clean\tsquash\tchecks none\nmay\tmerge,force,base");
fails(
&owner,
&["pull", "merge", &url, &pull, "--check", "--queue"],
);
// Merged by the knotless binary, which commits as git does on the
// owner's machine.
let (merged, sha, err) = owner.knotless(&["pull", "merge", &url, &pull], b"");
assert!(merged, "{err}");
let check = owner.path("check");
owner.git(owner.dir.path(), &["clone", "-q", &url, "check"]);
assert_eq!(owner.git(&check, &["rev-parse", "HEAD"]), sha);
assert_eq!(owner.git(&check, &["rev-parse", "HEAD^"]), base);
assert_eq!(owner.git(&check, &["rev-list", "--count", "HEAD"]), "2");
assert!(check.join("a").exists() && check.join("b").exists());
let message = owner.git(&check, &["log", "-1", "--format=%an|%cn <%ce>|%B"]);
// The head's author wrote it, and the owner, who merged it, committed it.
assert_eq!(
message,
format!("Test|Test <t@example.com>|Add a and b\n\nboth\n\n{pull}")
);
}
/// A merged pull reverts as GitHub's Revert button does: one commit that
/// undoes what its merge brought in, on a branch cut from the base, and a
/// pull of it, by the owner or a collaborator; whether it fast-forwarded,
/// merged or squashed. Not one still open, nor one whose undoing conflicts.
#[test]
fn merged_pulls_revert_in_pulls_of_their_own() {
let av = appview();
let owner = person(account(), &av);
let contrib = person(account(), &av);
let stranger = person(account(), &av);
let url = owner.url("reverted");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let refused = |who: &Sandbox, args: &[&str], why: &str| {
let (done, _, err) = who.knotless(args, b"");
assert!(!done && err.contains(why), "{args:?}: {err}");
};
// A branch of the repository from main as it is now, with `files`.
let branch = |name: &str, files: &[&str]| {
owner.git(&work, &["fetch", "-q", &url, "main"]);
owner.git(&work, &["checkout", "-q", "-B", name, "FETCH_HEAD"]);
for f in files {
owner.commit_file(&work, f, &format!("{f}\n"));
}
owner.git(&work, &["push", "-q", "-f", &url, name]);
};
let pull = |into: &str, name: &str, title: &str| {
let pull = ok(&owner, &["pull", "create", &url, into, &url, name, title]);
listed(&owner, &url, &pull);
pull
};
// The files at the tip of `branch`.
let files = |branch: &str| {
owner.git(&work, &["fetch", "-q", &url, branch]);
owner.git(&work, &["ls-tree", "--name-only", "FETCH_HEAD"])
};
let revert_of = |pull: &str| format!("revert/{}", pull.rsplit('/').next().unwrap());
// A merge commit on a base that had not moved: not until it merges,
// and not by just anyone.
branch("b", &["b"]);
let add_b = pull("main", "b", "Add b");
let open = "is open: only a merged pull reverts";
refused(&owner, &["pull", "revert", &url, &add_b], open);
let merged_b = ok(&owner, &["pull", "merge", &url, &add_b]);
refused(
&stranger,
&["pull", "revert", &url, &add_b],
"only the owner",
);
fails(&owner, &["pull", "revert", &url, &add_b, "--bogus"]);
let why = ["pull", "revert", &url, &add_b, "-m", "It broke the build"];
let reverting = ok(&owner, &why);
let mine = format!("at://{}/dev.knotless.pull/", owner.account.did);
assert!(reverting.starts_with(&mine), "{reverting}");
let undo_b = revert_of(&add_b);
assert_eq!(files(&undo_b), "f");
let said = owner.git(&work, &["log", "-1", "--format=%s|%P", "FETCH_HEAD"]);
let tip = merged_b.lines().next().unwrap();
assert_eq!(said, format!("Revert \"Add b\"|{tip}"));
refused(
&owner,
&["pull", "revert", &url, &add_b],
"has a branch revert/",
);
// Its pull, into main, says what it reverts and why.
listed(&owner, &url, &format!("{reverting}\tRevert \"Add b\""));
let shown = ok(&owner, &["pull", "show", &url, &reverting]);
assert!(
shown.contains("Reverts [Add b](") && shown.contains(&add_b),
"{shown}"
);
assert!(shown.contains("It broke the build"), "{shown}");
assert!(shown.contains(&format!("reverts {add_b}")), "{shown}");
// Not undone until the revert merges.
let before = ok(&owner, &["pull", "show", &url, &add_b]);
assert!(!before.contains("reverted by"), "{before}");
// A merge commit, reverted by a collaborator through the gateway: what
// main took since stays.
ok(
&owner,
&["account", "delegate", &owner.account.handle, &av.did],
);
ok(
&owner,
&["repo", "share", &url, &contrib.account.handle, "--write"],
);
branch("c", &["c"]);
branch("d", &["d"]);
owner.git(&work, &["push", "-q", &url, "d:main"]);
let add_c = pull("main", "c", "Add c");
ok(&owner, &["pull", "merge", &url, &add_c]);
ok(&contrib, &["pull", "revert", &url, &add_c]);
assert_eq!(files(&revert_of(&add_c)), "b\nd\nf");
// Once a revert lands, there is nothing left to revert.
ok(&owner, &["pull", "merge", &url, &reverting]);
refused(&owner, &["pull", "revert", &url, &add_b], "holds nothing");
// The pull it undid names it.
listed(&owner, &url, &format!("pull\tmerged\t{reverting}"));
let undone = ok(&owner, &["pull", "show", &url, &add_b]);
assert!(
undone.contains(&format!("reverted by {reverting}")),
"{undone}"
);
// A squash, of two commits, into a linear branch.
ok(&owner, &["branch", "protect", &url, "main"]);
branch("ef", &["e", "f2"]);
let add_ef = pull("main", "ef", "Add e and f2");
ok(&owner, &["pull", "merge", &url, &add_ef]);
assert_eq!(files("main"), "c\nd\ne\nf\nf2");
ok(&owner, &["pull", "revert", &url, &add_ef]);
assert_eq!(files(&revert_of(&add_ef)), "c\nd\nf");
// Changed since on main, it conflicts, and nothing moves.
branch("g", &["g"]);
let add_g = pull("main", "g", "Add g");
ok(&owner, &["pull", "merge", &url, &add_g]);
branch("g2", &[]);
owner.commit_file(&work, "g", "changed\n");
owner.git(&work, &["push", "-q", &url, "g2:main"]);
refused(
&owner,
&["pull", "revert", &url, &add_g],
"reverting conflicts in g",
);
assert!(!ok(&owner, &["repo", "refs", &url]).contains(&revert_of(&add_g)));
// A branch that no longer holds the merge has nothing to revert.
owner.git(&work, &["push", "-q", &url, "main:side"]);
branch("h", &["h"]);
let into_side = pull("side", "h", "Add h");
ok(&owner, &["pull", "merge", &url, &into_side]);
ok(&owner, &["branch", "delete", &url, "side"]);
refused(
&owner,
&["pull", "revert", &url, &into_side],
"side no longer holds",
);
}
/// A pull's branch brought up to date with its base, as GitHub's Update
/// branch button does: the base merged into it and pushed there, by the
/// owner or a collaborator. Lines both sides only added at the same place
/// are kept, the base's first; any other conflict leaves it as it was.
#[test]
fn pull_branches_update_from_their_base() {
let av = appview();
let owner = person(account(), &av);
let stranger = person(account(), &av);
let url = owner.url("updated");
let work = owner.work("w", 1);
owner.commit_file(&work, "notes", "one\n");
owner.git(&work, &["push", "-q", &url, "main"]);
let refused = |who: &Sandbox, why: &str, pull: &str| {
let (done, _, err) = who.knotless(&["pull", "update-branch", &url, pull], b"");
assert!(!done && err.contains(why), "{err}");
};
// `notes` as `branch` holds it, pushed there.
let write = |branch: &str, notes: &str| {
owner.git(&work, &["fetch", "-q", &url, branch]);
owner.git(&work, &["checkout", "-q", "-B", branch, "FETCH_HEAD"]);
owner.commit_file(&work, "notes", notes);
owner.git(&work, &["push", "-q", &url, branch]);
};
owner.git(&work, &["push", "-q", &url, "main:notes"]);
write("notes", "one\nmine\n");
let pull = ok(
&owner,
&["pull", "create", &url, "main", &url, "notes", "Notes"],
);
listed(&owner, &url, &pull);
// Holding its base already, it is left as it is.
let said = ok(&owner, &["pull", "update-branch", &url, &pull]);
assert_eq!(said, "notes holds its base already");
// Main added a line where the pull did: both stay, main's first.
write("main", "one\nmain\n");
refused(&stranger, "only the owner", &pull);
let said = ok(&owner, &["pull", "update-branch", &url, &pull]);
let merge = said.lines().next().unwrap();
let merge = merge.strip_prefix("merged its base into notes: ").unwrap();
assert!(
said.ends_with("both sides only added lines in notes: the base's come first"),
"{said}"
);
owner.git(&work, &["fetch", "-q", &url, "notes"]);
assert_eq!(owner.git(&work, &["rev-parse", "FETCH_HEAD"]), merge);
assert_eq!(
owner.git(&work, &["show", "FETCH_HEAD:notes"]),
"one\nmain\nmine"
);
let subject = owner.git(&work, &["log", "-1", "--format=%s", "FETCH_HEAD"]);
assert_eq!(subject, "Merge main into notes");
let parents = owner.git(&work, &["log", "-1", "--format=%P", "FETCH_HEAD"]);
assert_eq!(parents.split(' ').count(), 2, "{parents}");
// Both changing the same line, it is the author's to merge.
write("main", "uno\nmain\n");
write("notes", "one!\nmain\nmine\n");
refused(&owner, "same lines in notes: merge by hand", &pull);
owner.git(&work, &["fetch", "-q", &url, "notes"]);
assert_eq!(
owner.git(&work, &["show", "FETCH_HEAD:notes"]),
"one!\nmain\nmine"
);
}
#[test]
fn issues_and_who_may_close_them() {
let av = appview();
let mut owner = person(account(), &av);
let author = person(account(), &av);
let stranger = person(account(), &av);
let url = owner.url("tracked");
ok(&owner, &["repo", "create", &url]);
let issue = ok(
&author,
&["issue", "create", &url, "It breaks", "-m", "when I do this"],
);
let quiet = ok(&author, &["issue", "create", &url, "Also this"]);
listed(&owner, &url, &issue);
listed(&owner, &url, &quiet);
assert_eq!(ok(&author, &["issue", "close", &url, &issue]), "closed");
listed(&owner, &url, &format!("issue\tclosed\t{issue}"));
// Nothing is written that readers would only ignore: a stranger's
// reopening, closing what is closed already, or an issue that is not
// there, as one named under the wrong account.
let refused = |who: &Sandbox, args: &[&str], why: &str| {
let (done, _, err) = who.knotless(args, b"");
assert!(!done && err.contains(why), "{args:?}: {err}");
};
refused(
&stranger,
&["issue", "reopen", &url, &issue],
"may reopen it",
);
refused(
&author,
&["issue", "close", &url, &issue],
"is closed already",
);
let elsewhere = issue.replace(&author.account.did, &stranger.account.did);
refused(
&stranger,
&["issue", "close", &url, &elsewhere],
"no pull or issue",
);
assert!(stranger.records("dev.knotless.state").is_empty());
assert_eq!(author.records("dev.knotless.state").len(), 1);
// The owner's does.
ok(&owner, &["issue", "reopen", &url, &issue]);
listed(&owner, &url, &format!("issue\topen\t{issue}"));
assert_eq!(ok(&owner, &["pull", "list", &url]), "");
assert!(ok(&owner, &["pull", "show", &url, &quiet]).ends_with(": Also this"));
// One just written, which the appview's index does not have yet, is
// read from its author's repo.
let behind = appview_following(false, "");
let fresh = ok(&author, &["issue", "create", &url, "Just now"]);
owner.set("KNOTLESS_APPVIEW", &behind.base);
assert!(ok(&owner, &["pull", "show", &url, &fresh]).ends_with(": Just now"));
owner.set("KNOTLESS_APPVIEW", &av.base);
fails(&owner, &["issue", "list", &url, "--bogus"]);
fails(&owner, &["pull", "merge", &url, &issue]);
fails(
&owner,
&[
"issue",
"show",
&url,
"at://did:plc:nobody/dev.knotless.issue/x",
],
);
// A comment is about a pull or an issue on this repository.
fails(
&owner,
&[
"comment",
"add",
&url,
"at://did:plc:x/dev.knotless.state/3abc",
"?",
],
);
fails(
&owner,
&[
"comment",
"add",
&url,
&format!("{}x", issue.trim_end_matches(|c: char| c.is_alphanumeric())),
"?",
],
);
ok(&owner, &["repo", "create", &owner.url("elsewhere")]);
fails(
&owner,
&["comment", "add", &owner.url("elsewhere"), &issue, "?"],
);
fails(&owner, &["issue", "create", &url, "t", "-x"]);
fails(&owner, &["issue", "create", &url, "t", "-m"]);
fails(&owner, &["repo", "fork", &owner.url("nothing")]);
// One's own is not forked: a fork is of someone else's.
let parsed = knotless::url::RepoUrl::parse(&url).unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let owned = ctx(&owner);
let own = knotless::forge::fork(&owned, &parsed, Some("tracked-copy"));
let why = format!("{:#}", rt.block_on(own).unwrap_err());
assert!(
why.ends_with("a fork is of someone else's repository"),
"{why}"
);
// Its author changes what an issue or a comment says, and takes a
// comment back; nobody else can, and the web pages only read.
fails(
&owner,
&["issue", "edit", &url, &issue, "--title", "Hijacked"],
);
fails(&author, &["issue", "edit", &url, &issue]);
fails(&author, &["issue", "edit", &url, &issue, "--sideways", "x"]);
let state = format!("at://{}/dev.knotless.state/3abc", author.account.did);
fails(&author, &["pull", "edit", &url, &state, "-m", "x"]);
// What another client wrote into it, that knotless does not know, is
// kept through an edit.
let rkey = issue.rsplit('/').next().unwrap();
let mut held = author.record("dev.knotless.issue", rkey);
held["dev.example.labels"] = serde_json::json!(["bug"]);
author.put_record("dev.knotless.issue", rkey, held.clone());
assert_eq!(
ok(
&author,
&[
"issue",
"edit",
&url,
&issue,
"--title",
"It breaks badly",
"-m",
"every time"
]
),
"edited"
);
wait_until(|| {
let shown = ok(&owner, &["issue", "show", &url, &issue]);
shown.contains("It breaks badly") && shown.contains("every time")
});
let edited = author.record("dev.knotless.issue", rkey);
assert_eq!(edited["dev.example.labels"], held["dev.example.labels"]);
assert_eq!(edited["createdAt"], held["createdAt"]);
assert!(edited["updatedAt"].as_str().unwrap() > held["createdAt"].as_str().unwrap());
// The appview's index has it as edited, for the listing to show.
wait_until(|| ok(&owner, &["issue", "list", &url]).contains("It breaks badly"));
ok(&author, &["issue", "edit", &url, &issue, "-m", ""]);
wait_until(|| !ok(&owner, &["issue", "show", &url, &issue]).contains("every time"));
let said = ok(&author, &["comment", "add", &url, &issue, "first try"]);
fails(&author, &["pull", "edit", &url, &said, "--title", "x"]);
fails(&author, &["pull", "edit", &url, &said, "-m", ""]);
ok(&author, &["pull", "edit", &url, &said, "-m", "second try"]);
wait_until(|| ok(&owner, &["issue", "show", &url, &issue]).contains("second try"));
fails(&owner, &["comment", "delete", &url, &said]);
fails(&author, &["comment", "delete", &url, &issue]);
assert_eq!(ok(&author, &["comment", "delete", &url, &said]), "deleted");
wait_until(|| !ok(&owner, &["issue", "show", &url, &issue]).contains("second try"));
// One gone, or about another repository, is not there to change.
fails(&author, &["comment", "delete", &url, &said]);
fails(
&author,
&[
"issue",
"edit",
&owner.url("elsewhere"),
&issue,
"--title",
"x",
],
);
// Without an appview there is nowhere to find them.
let mut lost = person(owner.account.clone(), &av);
lost.set("KNOTLESS_APPVIEW", "");
let (code, out) = lost.cli(&["issue", "list", &url], b"");
assert_eq!(code, 1, "{out}");
// A server without an index says so.
let s = Sandbox::new();
let rt = tokio::runtime::Runtime::new().unwrap();
let listener = rt
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let bare = App::new(ctx(&s), Duration::ZERO);
let router = router(bare.clone());
rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
for path in [
"dev.knotless.getForge?repo=at://did:plc:a/dev.knotless.repo/r&key=k",
"dev.knotless.listRepos",
"dev.knotless.listChecks?repo=at://did:plc:a/dev.knotless.repo/r&key=k",
"com.atproto.label.queryLabels?uriPatterns=*",
] {
let res = reqwest::blocking::get(format!("{base}/xrpc/{path}")).unwrap();
assert_eq!(res.status().as_u16(), 404);
}
// Nor can it follow a labeler, and an account is not one: each try
// fails, and it tries again.
for (app, did) in [
(&bare, av.did.clone()),
(&av.app, owner.account.did.clone()),
] {
let following = knotless_serve::moderation::follow(app.clone(), did);
let _ =
rt.block_on(async { tokio::time::timeout(Duration::from_secs(1), following).await });
}
// A repository is named by its record's URI.
let res = reqwest::blocking::get(format!(
"{}/xrpc/dev.knotless.getForge?repo=x&key=k",
av.base
))
.unwrap();
assert_eq!(res.status().as_u16(), 400);
// Every record comes back, a page at a time.
let key = owner.record("dev.knotless.repo", "tracked")["key"].clone();
let repo = format!("at://{}/dev.knotless.repo/tracked", owner.account.did);
let forge = |cursor: &str| -> Value {
reqwest::blocking::get(format!(
"{}/xrpc/dev.knotless.getForge?repo={repo}&key={}&limit=1&cursor={cursor}",
av.base,
key.as_str().unwrap()
))
.unwrap()
.json()
.unwrap()
};
let (mut paged, mut cursor) = (0, String::new());
loop {
let page = forge(&cursor);
assert!(page["entries"].as_array().unwrap().len() <= 1);
paged += page["entries"].as_array().unwrap().len();
match page["cursor"].as_str() {
Some(next) => cursor = next.to_string(),
None => break,
}
}
assert!(paged > 1, "{paged}");
}
#[test]
fn labels_and_who_may_put_them_on() {
let av = appview();
let owner = person(account(), &av);
let collab = person(account(), &av);
let author = person(account(), &av);
let url = owner.url("labelled");
ok(&owner, &["repo", "create", &url]);
// The owner defines them, and only the owner.
let bug = ["--color", "#d73a4a", "--description", "Something broken"];
ok(
&owner,
&[&["label", "define", &url, "kind/bug"][..], &bug].concat(),
);
ok(
&owner,
&["label", "define", &url, "priority/high", "--exclusive"],
);
ok(
&owner,
&["label", "define", &url, "priority/low", "--exclusive"],
);
let defined = ok(&owner, &["label", "list", &url]);
assert!(
defined.starts_with("kind/bug\t#d73a4a\t\tSomething broken\npriority/high\t\texclusive"),
"{defined}"
);
// Defining one again replaces it in its place.
let low = [
"labels",
&url,
"add",
"priority/low",
"--exclusive",
"--color",
"#0e8a16",
];
let redefined = ok(&owner, &low);
assert_eq!(redefined.lines().count(), 3, "{redefined}");
assert!(
redefined.ends_with("priority/low\t#0e8a16\texclusive"),
"{redefined}"
);
for bad in [
&["label", "define", &url, "x", "--color", "red"][..],
&["label", "define", &url, ""],
&["label", "define", &url, "x", "--bogus"],
&["label", "delete", &url, "nothing"],
&["label", "bogus", &url],
&["label", "list", &owner.url("nothing")],
&["label", "define", &owner.url("nothing"), "x"],
] {
fails(&owner, bad);
}
fails(&author, &["label", "define", &url, "mine"]);
// Only the owner and collaborators put them on, and only those defined.
let issue = ok(&author, &["issue", "create", &url, "It breaks"]);
fails(&author, &["issue", "label", &url, &issue, "kind/bug"]);
fails(&owner, &["issue", "label", &url, &issue, "kind/feature"]);
fails(&owner, &["issue", "label", &url, &issue]);
// What one says, the others see once the index has it.
let shows = |labels: &str| {
wait_until(|| {
let shown = author.cli(&["issue", "show", &url, &issue], b"").1;
shown.lines().nth(1) == Some(labels)
})
};
let set = ok(
&owner,
&["issue", "label", &url, &issue, "kind/bug", "priority/high"],
);
assert_eq!(set, "kind/bug\npriority/high");
shows("labels: kind/bug, priority/high");
// The pages show each as the repository defines it.
let found = |q: &str| -> Value {
let at = reqwest::Url::parse_with_params(
&format!("{}/xrpc/dev.knotless.listItems", av.base),
[
(
"repo",
format!("at://{}/dev.knotless.repo/labelled", owner.account.handle).as_str(),
),
("kind", "issue"),
("q", q),
],
);
reqwest::blocking::get(at.unwrap()).unwrap().json().unwrap()
};
let listed = || found("");
let defined = serde_json::json!([
{"name": "kind/bug", "color": "#d73a4a", "description": "Something broken"},
{"name": "priority/high", "exclusive": true},
]);
wait_until(|| listed()["items"][0]["labels"] == defined);
// A query filters them, and the counts are of those it matches; the
// answer names what a filter can.
let all = listed();
assert_eq!(all["labels"].as_array().unwrap().len(), 3, "{all}");
assert_eq!(all["labels"][2]["name"], "priority/low");
assert_eq!(all["authors"][0]["handle"], author.account.handle.as_str());
let counts = |q: &str| {
let a = found(q);
let shown = a["items"].as_array().unwrap().len();
(
shown,
a["openCount"].as_u64().unwrap(),
a["closedCount"].as_u64().unwrap(),
)
};
let by = format!("author:{} label:kind/bug breaks", author.account.handle);
assert_eq!(counts(&by), (1, 1, 0));
assert_eq!(counts("label:kind/bug label:priority/low"), (0, 0, 0));
assert_eq!(counts("-label:priority/high"), (0, 0, 0));
assert_eq!(counts("author:nobody.invalid"), (0, 0, 0));
assert_eq!(counts("state:closed label:kind/bug"), (0, 1, 0));
assert_eq!(counts("is:merged"), (0, 1, 0));
// The CLI reads the same words.
let list = |args: &[&str]| {
ok(
&author,
&[
&[
if args.contains(&"--pulls") {
"pull"
} else {
"issue"
},
"list",
&url,
][..],
&args
.iter()
.copied()
.filter(|a| *a != "--issues" && *a != "--pulls")
.collect::<Vec<_>>()[..],
]
.concat(),
)
};
assert!(list(&["--issues", "-q", "label:kind/bug"]).contains(&issue));
assert!(list(&["-q", "label:priority/low"]).is_empty());
assert!(list(&["--pulls", "-q", "label:kind/bug"]).is_empty());
fails(&author, &["issue", "list", &url, "-q"]);
fails(&author, &["issue", "list", &url, "--bogus"]);
ok(
&owner,
&["account", "delegate", &owner.account.handle, &av.did],
);
ok(
&owner,
&["repo", "share", &url, &collab.account.handle, "--write"],
);
// A collaborator's exclusive label takes the owner's of its scope off,
// and taking it off brings none of them back.
let low = ok(&collab, &["issue", "label", &url, &issue, "priority/low"]);
assert_eq!(low, "kind/bug\npriority/low");
let off = ok(
&collab,
&["pull", "label", &url, &issue, "priority/low", "--undo"],
);
assert_eq!(off, "kind/bug");
shows("labels: kind/bug");
let high = ok(&owner, &["issue", "label", &url, &issue, "priority/high"]);
assert_eq!(high, "kind/bug\npriority/high");
shows("labels: kind/bug, priority/high");
// A triager puts on labels of its scopes alone, and they give way to
// a person's, whenever set.
let bot = author.account.handle.as_str();
let named = ok(&owner, &["triager", "add", &url, bot, "priority"]);
assert_eq!(named, format!("{}\tpriority", author.account.did));
assert_eq!(ok(&owner, &["triager", "list", &url]), named);
fails(&author, &["issue", "label", &url, &issue, "kind/bug"]);
let low = ok(&author, &["issue", "label", &url, &issue, "priority/low"]);
assert_eq!(low, "kind/bug\npriority/high");
for bad in [
&["triager", "add", &url, bot][..],
&["triager", "bogus", &url],
&["triager", "list", &owner.url("nothing")],
&["triager", "add", &owner.url("nothing"), bot, "x"],
] {
fails(&owner, bad);
}
assert_eq!(ok(&owner, &["triager", "remove", &url, bot]), "");
fails(&owner, &["triager", "remove", &url, bot]);
// A label taken away comes off everything it was on.
ok(&owner, &["label", "delete", &url, "kind/bug"]);
shows("labels: priority/high");
}
#[test]
fn private_repositories_keep_their_issues_in_the_space() {
if !net().spaces {
eprintln!("skipped: the PDS is not on the spaces alpha");
return;
}
let av = appview();
let owner = person(account(), &av);
let reader = person(account(), &av);
let url = owner.url("inner");
ok(&owner, &["repo", "create", &url, "--private"]);
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
ok(&owner, &["repo", "share", &url, &reader.account.handle]);
let issue = ok(&reader, &["issue", "create", &url, "Private trouble"]);
owner.git(&work, &["checkout", "-q", "-b", "fix"]);
let fix = owner.commit_file(&work, "fix", "fixed\n");
owner.git(&work, &["push", "-q", &url, "fix"]);
let pull = ok(
&owner,
&["pull", "create", &url, "main", &url, "fix", "Fix it"],
);
ok(
&reader,
&["comment", "add", &url, &pull, "ship it", "--approve"],
);
// No appview involved: the members' repos in the space are read directly.
let (_, listing) = both(&owner, &url);
assert!(
listing.contains(&format!("issue\topen\t{issue}")),
"{listing}"
);
assert!(
listing.contains(&format!("pull\topen\t{pull}")),
"{listing}"
);
// What its author wrote in the space, they edit and take back there.
ok(
&reader,
&[
"issue",
"edit",
&url,
&issue,
"--title",
"Private trouble, worse",
],
);
let said = ok(&reader, &["comment", "add", &url, &issue, "still broken"]);
ok(
&reader,
&["pull", "edit", &url, &said, "-m", "still broken, sadly"],
);
fails(&owner, &["pull", "edit", &url, &said, "-m", "fine now"]);
let shown = ok(&owner, &["issue", "show", &url, &issue]);
assert!(shown.contains("Private trouble, worse"), "{shown}");
assert!(shown.contains("still broken, sadly"), "{shown}");
ok(&reader, &["comment", "delete", &url, &said]);
assert!(!ok(&owner, &["issue", "show", &url, &issue]).contains("still broken"));
// Its pulls and issues are in the space, which cannot move.
fails(&owner, &["repo", "transfer", &url, &reader.account.handle]);
// The owner's gate and blocks hold in the space, with no appview.
ok(&owner, &["mod", "hide", &url, &issue]);
assert!(!ok(&reader, &["issue", "list", &url]).contains(&issue));
ok(&owner, &["mod", "hide", &url, &issue, "--undo"]);
ok(&owner, &["mod", "block", &reader.account.handle]);
assert!(!ok(&owner, &["issue", "list", &url]).contains(&issue));
ok(&owner, &["mod", "block", &reader.account.handle, "--undo"]);
assert!(ok(&owner, &["issue", "list", &url]).contains(&issue));
// A block from this repository alone is in the space with it.
ok(&owner, &["mod", "block", &reader.account.handle, &url]);
assert!(!ok(&owner, &["issue", "list", &url]).contains(&issue));
ok(
&owner,
&["mod", "block", &reader.account.handle, &url, "--undo"],
);
assert!(ok(&owner, &["issue", "list", &url]).contains(&issue));
let merged = ok(&owner, &["pull", "merge", &url, &pull]);
let (made, deleted) = merged.split_once('\n').unwrap();
assert!(made != fix && deleted == "deleted fix", "{merged}");
let listing = ok(&reader, &["pull", "list", &url]);
assert!(
listing.contains(&format!("pull\tmerged\t{pull}")),
"{listing}"
);
// The appview never saw any of it.
let uri = format!("at://{}/dev.knotless.repo/inner", owner.account.did);
assert_eq!(av.app.index.as_ref().unwrap().repo(&uri).unwrap(), None);
// A private repository forks into a private one.
let fork = ok(&reader, &["repo", "fork", &url, "inner-fork"]);
let (_, refs) = reader.cli(&["repo", "refs", &fork], b"");
assert!(refs.contains(&format!("{made}\trefs/heads/main")), "{refs}");
let anonymous = Sandbox::new();
assert_eq!(anonymous.cli(&["repo", "refs", &fork], b"").0, 1);
// Its source's owner reads it, to take a pull from it.
let (code, refs) = owner.cli(&["repo", "refs", &fork], b"");
assert_eq!(code, 0, "{refs}");
let readers = ok(&reader, &["repo", "members", &fork]);
assert!(readers.contains(owner.account.did.as_str()), "{readers}");
}
#[test]
fn moving_to_another_account() {
let av = appview();
let owner = person(account(), &av);
let heir = person(account(), &av);
let stranger = person(account(), &av);
let url = owner.url("moving");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let before = ok(&owner, &["repo", "refs", &url]);
let closed = ok(
&stranger,
&["issue", "create", &url, "Closed before the move"],
);
let open = ok(&stranger, &["issue", "create", &url, "Still open"]);
ok(&owner, &["pull", "close", &url, &closed]);
listed(&owner, &url, &format!("issue\tclosed\t{closed}"));
// Nothing moves until it is offered, and only to whom it is offered.
fails(&heir, &["repo", "accept", &url]);
fails(&owner, &["repo", "transfer", &url, &owner.account.handle]);
ok(&owner, &["repo", "transfer", &url, &heir.account.handle]);
fails(&stranger, &["repo", "accept", &url]);
ok(&owner, &["repo", "transfer", &url, "--undo"]);
fails(&heir, &["repo", "accept", &url]);
let elsewhere = format!("knotless://{}/moving", heir.account.handle);
ok(&owner, &["repo", "transfer", &url, &elsewhere]);
ok(&owner, &["repo", "transfer", &url, "--undo"]);
// Never over a repository of the heir's by the name it is offered under.
let held = heir.url("held");
ok(&heir, &["repo", "create", &held]);
let theirs = heir.record("dev.knotless.repo", "held");
ok(&owner, &["repo", "transfer", &url, &held]);
fails(&heir, &["repo", "accept", &url]);
assert_eq!(heir.record("dev.knotless.repo", "held"), theirs);
ok(&owner, &["repo", "transfer", &url, "--undo"]);
fails(
&owner,
&[
"repo",
"transfer",
&owner.url("nothing"),
&heir.account.handle,
],
);
let offered = ok(&owner, &["repo", "transfer", &url, &heir.account.handle]);
assert!(offered.contains("knotless repo accept"), "{offered}");
let moved = ok(&heir, &["repo", "accept", &url]);
let new = format!("knotless://{}/moving", heir.account.did);
assert_eq!(moved, format!("moved to {new}"));
// The old URL leads to the new place, for reads and pushes alike.
assert_eq!(ok(&heir, &["repo", "refs", &url]), before);
heir.git(heir.dir.path(), &["clone", "-q", &url, "clone"]);
let clone = heir.path("clone");
heir.commit_file(&clone, "g", "after the move\n");
let (pushed, _, err) = heir.git_raw(&clone, &["push", "-q", "origin", "main"]);
assert!(
pushed && err.contains(&format!("has moved to {new}")),
"{err}"
);
assert_ne!(ok(&heir, &["repo", "refs", &new]), before);
// A query by the old owner answers under the new one.
let asked = format!(
"{}/xrpc/dev.knotless.getRepo?repo=at://{}/dev.knotless.repo/moving",
av.base, owner.account.handle
);
let found: serde_json::Value = reqwest::blocking::get(asked).unwrap().json().unwrap();
assert_eq!(found["owner"]["handle"], heir.account.handle.as_str());
assert_eq!(found["owner"]["did"], heir.account.did.as_str());
// Its issues came with it, and what the old owner did before the move
// still counts; nothing they do after it does, so it is refused.
listed(&heir, &new, &format!("issue\tclosed\t{closed}"));
let (shut, _, err) = owner.knotless(&["pull", "close", &url, &open], b"");
assert!(!shut && err.contains("may close it"), "{err}");
listed(&heir, &new, &format!("issue\topen\t{open}"));
ok(&heir, &["comment", "add", &new, &open, "moved along"]);
ok(&heir, &["pull", "close", &new, &open]);
listed(&heir, &new, &format!("issue\tclosed\t{open}"));
// Archived, its pages say so, and nobody writes about it.
ok(&heir, &["repo", "archive", &new]);
fails(&heir, &["comment", "add", &new, &open, "too late"]);
let asked = format!(
"{}/xrpc/dev.knotless.getRepo?repo=at://{}/dev.knotless.repo/moving",
av.base, heir.account.did
);
let archived = || {
let found: Value = reqwest::blocking::get(&asked).unwrap().json().unwrap();
found["archivedAt"].is_string()
};
wait_until(archived);
ok(&heir, &["repo", "archive", &new, "--undo"]);
wait_until(|| !archived());
// The old copy can go, leaving a redirect in its place; its old names'
// redirects lead to the new place too, and others' stay as they were.
let key = owner.record("dev.knotless.repo", "moving")["key"].clone();
let at = format!("at://{}/dev.knotless.repo/moving", owner.account.did);
owner.put_record(
"dev.knotless.redirect",
"once-moving",
serde_json::json!({"$type": "dev.knotless.redirect", "to": {"uri": at, "key": key},
"createdAt": "2026-10-21T00:00:00.000Z"}),
);
ok(&owner, &["repo", "create", &owner.url("spare")]);
ok(&owner, &["repo", "rename", &owner.url("spare"), "spare2"]);
ok(&owner, &["repo", "delete", &url, "--yes"]);
assert!(
owner.record("dev.knotless.redirect", "once-moving")["to"]["uri"]
.as_str()
.unwrap()
.starts_with(&format!("at://{}/", heir.account.did))
);
assert_eq!(
owner.record("dev.knotless.redirect", "spare")["to"]["uri"],
format!("at://{}/dev.knotless.repo/spare2", owner.account.did)
);
assert!(
owner.record("dev.knotless.redirect", "moving")["to"]["uri"]
.as_str()
.unwrap()
.starts_with(&format!("at://{}/", heir.account.did))
);
assert_eq!(
ok(&stranger, &["repo", "refs", &url]),
ok(&stranger, &["repo", "refs", &new])
);
listed(&stranger, &url, &format!("issue\tclosed\t{closed}"));
// A raw file by the old owner is sent on to the heir's.
let direct = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let raw = |owner: &str| format!("/{owner}/moving/raw/main/a");
let old_raw = || {
let at = format!("{}{}", av.base, raw(&owner.account.handle));
direct.get(at).send().unwrap()
};
wait_until(|| old_raw().status().as_u16() == 307);
assert_eq!(
old_raw().headers()["location"],
raw(&heir.account.did).as_str()
);
// Its issues, asked of by the old owner, are the heir's.
let old_issues = || -> Value {
let at = reqwest::Url::parse_with_params(
&format!("{}/xrpc/dev.knotless.listItems", av.base),
[
(
"repo",
format!("at://{}/dev.knotless.repo/moving", owner.account.handle).as_str(),
),
("kind", "issue"),
("state", "closed"),
],
);
reqwest::blocking::get(at.unwrap()).unwrap().json().unwrap()
};
wait_until(|| {
let listed = old_issues()["items"]
.as_array()
.cloned()
.unwrap_or_default();
listed.iter().any(|i| i["uri"] == closed.trim())
});
// Saying a repository came from another proves nothing without its
// owner's word: none of its issues come along.
let other = owner.url("other");
owner.git(&work, &["push", "-q", &other, "main"]);
let theirs = ok(&stranger, &["issue", "create", &other, "Not yours"]);
listed(&owner, &other, &theirs);
let key = owner.record("dev.knotless.repo", "other")["key"].clone();
let from = serde_json::json!({"uri": format!("at://{}/dev.knotless.repo/other", owner.account.did), "key": key});
stranger.put_record(
"dev.knotless.repo",
"claimed",
serde_json::json!({"$type": "dev.knotless.repo", "key": "3kabcdefghijk", "name": "claimed",
"transferredFrom": from, "createdAt": "2026-10-21T00:00:00.000Z"}),
);
let claimed = stranger.url("claimed");
std::thread::sleep(Duration::from_secs(1));
assert!(!ok(&stranger, &["issue", "list", &claimed]).contains(&theirs));
}
/// Waits until `list` no longer shows `line`.
#[track_caller]
fn unlisted(s: &Sandbox, url: &str, line: &str) {
wait_until(|| {
let (ok, out) = both(s, url);
ok && !out.contains(line)
});
}
#[test]
fn moderating() {
let operator = account();
let av = appview_following(true, &operator.did);
let owner = person(account(), &av);
let troll = person(account(), &av);
let reporter = person(account(), &av);
let url = owner.url("moderated");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let trolling = ok(&troll, &["issue", "create", &url, "Trolling"]);
let trolling = trolling.trim();
let fair = ok(&reporter, &["issue", "create", &url, "A fair point"]);
let fair = fair.trim();
let reply = ok(&troll, &["comment", "add", &url, fair, "nonsense"]);
let reply = reply.trim();
listed(&owner, &url, trolling);
// The owner hides an issue, and what was said about it goes with it.
assert_eq!(ok(&owner, &["mod", "hide", &url, fair]), "hidden");
assert_eq!(ok(&owner, &["mod", "hide", &url, fair]), "hidden");
unlisted(&reporter, &url, fair);
assert_eq!(reporter.cli(&["pull", "show", &url, fair], b"").0, 1);
ok(&owner, &["mod", "hide", &url, reply]);
assert_eq!(ok(&owner, &["mod", "hide", &url, fair, "--undo"]), "shown");
listed(&reporter, &url, fair);
assert!(!ok(&reporter, &["pull", "show", &url, fair]).contains("nonsense"));
ok(&owner, &["mod", "hide", &url, reply, "--undo"]);
wait_until(|| ok(&reporter, &["pull", "show", &url, fair]).contains("nonsense"));
// Only the owner, and only what is said about a repository.
fails(&troll, &["mod", "hide", &url, fair]);
let repo_uri = format!("at://{}/dev.knotless.repo/moderated", owner.account.did);
fails(&owner, &["mod", "hide", &url, &repo_uri]);
// A block keeps out everything the account writes.
let blocked = ok(&owner, &["mod", "block", &troll.account.handle]);
assert_eq!(blocked, format!("blocked {}", troll.account.did));
ok(&owner, &["mod", "block", &troll.account.handle]);
let blocks = ok(&owner, &["mod", "blocks"]);
let everywhere = format!("{}\tall your repositories\t", troll.account.handle);
assert!(blocks.starts_with(&everywhere), "{blocks}");
assert_eq!(blocks.lines().count(), 1);
unlisted(&reporter, &url, trolling);
wait_until(|| !ok(&reporter, &["pull", "show", &url, fair]).contains("nonsense"));
fails(&owner, &["mod", "block", &owner.account.handle]);
ok(&owner, &["mod", "block", &troll.account.handle, "--undo"]);
listed(&reporter, &url, trolling);
// Or keeps them out of one repository only, and only its owner can.
ok(&owner, &["mod", "block", &troll.account.handle, &url]);
ok(&owner, &["mod", "block", &troll.account.handle, &url]);
let one = format!("{}\t{url}\t", troll.account.handle);
assert!(ok(&owner, &["mod", "blocks"]).starts_with(&one));
unlisted(&reporter, &url, trolling);
// Nor does it show by its URI, however new.
assert_eq!(reporter.cli(&["pull", "show", &url, trolling], b"").0, 1);
fails(&reporter, &["mod", "block", &troll.account.handle, &url]);
ok(
&owner,
&["mod", "block", &troll.account.handle, &url, "--undo"],
);
listed(&reporter, &url, trolling);
// Reports go to the appview's moderation service, from an OAuth login
// too, whose scope names the call.
let handle = reporter.account.handle.clone();
assert_eq!(reporter.cli(&["login", &handle], b"").0, 0);
let sent = ok(
&reporter,
&[
"mod",
"report",
trolling,
"spam",
"-m",
"the same thing again",
],
);
assert_eq!(sent, "report 1 sent");
ok(&reporter, &["mod", "report", &troll.account.handle, "rude"]);
ok(&reporter, &["mod", "report", &url, "other"]);
fails(&reporter, &["mod", "report", &url, "boring"]);
fails(
&reporter,
&["mod", "report", &url, "spam", "--image", "shot.png"],
);
fails(
&reporter,
&["mod", "report", &format!("{trolling}x"), "spam"],
);
// Only an operator reads them.
fails(&reporter, &["admin", "reports"]);
let admin = person(operator, &av);
let reports = ok(&admin, &["admin", "reports"]);
let lines: Vec<&str> = reports.lines().collect();
assert_eq!(lines.len(), 3, "{reports}");
assert!(
lines[0].starts_with("3\t") && lines[0].contains(&repo_uri),
"{reports}"
);
assert!(
lines[1].contains(&format!("rude\t{}", troll.account.did)),
"{reports}"
);
assert_eq!(
lines[2],
format!(
"1\t{}\tspam\t{trolling}\tthe same thing again",
reporter.account.did
)
);
assert_eq!(ok(&admin, &["admin", "reports", "1"]).lines().count(), 1);
fails(&admin, &["admin", "reports", "x"]);
fails(&admin, &["admin", "nothing"]);
// Without an appview, reports go where KNOTLESS_MODERATOR says.
let mut direct = person(reporter.account.clone(), &av);
direct.set("KNOTLESS_APPVIEW", "");
fails(&direct, &["mod", "report", trolling, "spam"]);
direct.set("KNOTLESS_MODERATOR", &owner.account.did);
fails(&direct, &["mod", "report", trolling, "spam"]);
fails(&reporter, &["mod", "report", &owner.url("nothing"), "spam"]);
direct.set("KNOTLESS_MODERATOR", &av.did);
assert_eq!(
ok(&direct, &["mod", "report", trolling, "spam"]),
"report 4 sent"
);
// A second appview follows this one's labels.
let other = appview();
other.rt.spawn(knotless_serve::moderation::follow(
other.app.clone(),
av.did.clone(),
));
let down_there = |subject: &str| other.app.index.as_ref().unwrap().down(&[subject]).unwrap();
// The operator takes things down.
assert_eq!(ok(&admin, &["admin", "status", trolling]), "up");
assert_eq!(ok(&admin, &["admin", "takedown", trolling]), "taken down");
assert_eq!(ok(&admin, &["admin", "status", trolling]), "taken down");
unlisted(&reporter, &url, trolling);
wait_until(|| down_there(trolling));
fails(&admin, &["admin", "takedown", trolling, "--later"]);
assert_eq!(
ok(&admin, &["admin", "takedown", trolling, "--undo"]),
"restored"
);
listed(&reporter, &url, trolling);
wait_until(|| !down_there(trolling));
// An account taken down takes its repositories with it.
let page = || {
reqwest::blocking::get(format!(
"{}/xrpc/dev.knotless.getRepo?repo=at://{}/dev.knotless.repo/moderated",
av.base, owner.account.handle
))
.unwrap()
.status()
.as_u16()
};
assert_eq!(page(), 200);
ok(&admin, &["admin", "takedown", &owner.account.handle]);
assert_eq!(
ok(&admin, &["admin", "status", &owner.account.handle]),
"taken down"
);
assert_eq!(page(), 404);
let listing = reqwest::blocking::get(format!("{}/xrpc/dev.knotless.listRepos", av.base))
.unwrap()
.text()
.unwrap();
assert!(!listing.contains(&owner.account.did), "{listing}");
let labels: Value = reqwest::blocking::get(format!(
"{}/xrpc/com.atproto.label.queryLabels?uriPatterns=did:*&uriPatterns={}",
av.base, trolling
))
.unwrap()
.json()
.unwrap();
assert_eq!(labels["labels"][0]["uri"], owner.account.did.as_str());
assert_eq!(labels["labels"][0]["val"], "!takedown");
assert_eq!(labels["labels"].as_array().unwrap().len(), 1);
wait_until(|| down_there(&owner.account.did));
ok(&admin, &["admin", "takedown", &url]);
assert_eq!(ok(&admin, &["admin", "status", &url]), "taken down");
ok(
&admin,
&["admin", "takedown", &owner.account.handle, "--undo"],
);
assert_eq!(page(), 404);
ok(&admin, &["admin", "takedown", &url, "--undo"]);
assert_eq!(page(), 200);
// The calls themselves, by hand.
let client = reqwest::blocking::Client::new();
let xrpc = |nsid: &str| format!("{}/xrpc/{nsid}", av.base);
let post = |nsid: &str, auth: Option<&str>, body: Value| {
let mut req = client.post(xrpc(nsid)).json(&body);
if let Some(auth) = auth {
req = req.header("authorization", auth);
}
req.send().unwrap().status().as_u16()
};
// An operator's calls carry a service token for the method, from
// their PDS.
let token = |who: &Sandbox, lxm: &str| {
let ctx = ctx(who);
let token = av.rt.block_on(async {
let me = knotless::session::me(&ctx).await.unwrap();
knotless::auth::service_token(&ctx, &me, &av.did, lxm)
.await
.unwrap()
});
format!("Bearer {token}")
};
let status = "com.atproto.admin.updateSubjectStatus";
let operating = token(&admin, status);
assert_eq!(post(status, None, serde_json::json!({})), 401);
assert_eq!(
post(status, Some(&operating), serde_json::json!({"subject": {}})),
400
);
let account =
serde_json::json!({"$type": "com.atproto.admin.defs#repoRef", "did": "did:plc:x"});
let down = serde_json::json!({"subject": account});
assert_eq!(post(status, Some(&operating), down.clone()), 200);
// Not from anyone else, nor with a token for another method.
assert_eq!(
post(status, Some(&token(&reporter, status)), down.clone()),
403
);
let report = "com.atproto.moderation.createReport";
assert_eq!(post(status, Some(&token(&admin, report)), down), 401);
let get_status = "com.atproto.admin.getSubjectStatus";
let looking = token(&admin, get_status);
let get_status = |q: &str| {
client
.get(xrpc(&format!("{get_status}{q}")))
.header("authorization", &looking)
.send()
.unwrap()
.status()
.as_u16()
};
assert_eq!(get_status(""), 400);
assert_eq!(get_status("?did=did:plc:x"), 200);
assert_eq!(post(report, None, serde_json::json!({})), 401);
let bearer = token(&reporter, report);
assert_eq!(
post(
report,
Some(&bearer),
serde_json::json!({"subject": account})
),
400
);
assert_eq!(
post(report, Some(&bearer), serde_json::json!({"subject": {}})),
400
);
let page2 = client
.get(xrpc("dev.knotless.listReports?limit=2"))
.header("authorization", token(&admin, "dev.knotless.listReports"))
.send()
.unwrap()
.json::<Value>()
.unwrap();
assert_eq!(page2["cursor"], "3");
// The label stream, from the start: everything given out so far, then
// what comes; from past the end, only what comes after it.
let stream = |cursor: Option<i64>| {
let url = format!(
"{}/xrpc/com.atproto.label.subscribeLabels{}",
av.base.replace("http", "ws"),
cursor.map(|c| format!("?cursor={c}")).unwrap_or_default()
);
av.rt
.block_on(tokio_tungstenite::connect_async(url))
.unwrap()
.0
};
let (mut from_start, mut from_end) = (stream(Some(0)), stream(Some(1000)));
let next = |ws: &mut tokio_tungstenite::WebSocketStream<_>| {
let msg = av.rt.block_on(ws.next()).unwrap().unwrap();
knotless_serve::moderation::parse(&msg.into_data()).unwrap()
};
assert_eq!(next(&mut from_start).0, 1);
for _ in 2..=6 {
next(&mut from_start);
}
ok(&admin, &["admin", "takedown", trolling]);
let (seq, labels) = next(&mut from_start);
assert_eq!((seq, labels[0]["uri"].as_str()), (7, Some(trolling)));
let ping = tokio_tungstenite::tungstenite::Message::Ping(vec![].into());
av.rt.block_on(from_start.send(ping)).unwrap();
av.rt.block_on(from_start.close(None)).unwrap();
av.rt.block_on(from_end.close(None)).unwrap();
av.rt.block_on(stream(None).close(None)).unwrap();
ok(&admin, &["admin", "takedown", trolling, "--undo"]);
// Labels follow from where they were read to.
let labels = other.app.index.as_ref().unwrap();
assert!(labels.labeler_seq(&av.did).unwrap().is_some());
// One taken down as soon as it is opened does not show by its URI,
// indexed yet or not.
let fresh = ok(&troll, &["issue", "create", &url, "Again"]);
let fresh = fresh.trim();
ok(&admin, &["admin", "takedown", fresh]);
assert_eq!(reporter.cli(&["pull", "show", &url, fresh], b"").0, 1);
}
#[test]
fn the_merge_queue_is_for_who_may_merge() {
let av = appview();
let owner = person(account(), &av);
let contrib = person(account(), &av);
let stranger = person(account(), &av);
let url = owner.url("queued");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let pull = |s: &Sandbox, branch: &str| {
owner.git(&work, &["checkout", "-q", "-B", branch, "main"]);
owner.commit_file(&work, branch, "x\n");
owner.git(&work, &["push", "-q", &url, branch]);
let pull = ok(s, &["pull", "create", &url, "main", &url, branch, branch]);
listed(&owner, &url, &pull);
pull
};
let x = pull(&owner, "x");
let refused = |who: &Sandbox, args: &[&str], why: &str| {
let (done, _, err) = who.knotless(args, b"");
assert!(!done && err.contains(why), "{args:?}: {err}");
};
assert_eq!(
ok(&owner, &["queue", "list", &url]),
"the merge queue is empty"
);
// With no runner to land it, there is no queue.
refused(
&owner,
&["pull", "merge", &url, &x, "--queue"],
"names no runners",
);
ok(&owner, &["runner", "add", &url, &contrib.account.handle]);
// Nor is there one until a rule of the branch turns it on.
refused(
&owner,
&["pull", "merge", &url, &x, "--queue"],
"main does not use the merge queue",
);
refused(&owner, &["pull", "merge", &url, &x, "--queue"], "--queue`");
ok(&owner, &["branch", "protect", &url, "main", "--queue"]);
refused(
&owner,
&["pull", "merge", &url, &x, "--queue", "--wait", "5"],
"--queue takes none of",
);
// Someone who may not merge it may not queue it, nor take it out.
refused(
&contrib,
&["pull", "merge", &url, &x, "--queue"],
"only the owner and collaborators",
);
refused(
&contrib,
&["queue", "leave", &url, &x],
"only the owner and collaborators",
);
let key = owner.record("dev.knotless.repo", "queued")["key"].clone();
let repo = serde_json::json!({
"uri": format!("at://{}/dev.knotless.repo/queued", owner.account.did),
"key": key,
});
let record = |state: &str| {
serde_json::json!({
"$type": "dev.knotless.queue", "repo": repo, "subject": x,
"state": state, "reason": "why not", "createdAt": "2026-10-21T00:00:00.000Z",
})
};
// The owner queues it, and again: it keeps its place.
let queued = ok(&owner, &["pull", "merge", &url, &x, "--queue"]);
assert!(
queued.starts_with("queued into main: 1 of 1\nA runner lands it"),
"{queued}"
);
wait_until(|| ok(&owner, &["queue", "list", &url]).contains(&x));
stranger.put_record("dev.knotless.queue", &knotless::keys::tid(), record("left"));
assert!(
ok(&owner, &["pull", "merge", &url, &x, "--queue"])
.starts_with("queued into main: 1 of 1\n")
);
assert_eq!(
ok(&owner, &["queue", "list", &url]),
format!("1\tmain\tqueued\t{x}\tx")
);
let listing: Value =
serde_json::from_str(&ok(&owner, &["queue", "list", &url, "--json"])).unwrap();
assert_eq!(listing[0]["queue"]["by"], owner.account.did.as_str());
assert_eq!(
listing[0]["place"],
serde_json::json!({"base": "main", "position": 1, "of": 1})
);
let shown = ok(&owner, &["pull", "show", &url, &x]);
assert!(shown.contains("\nmerge queue: queued at "), "{shown}");
// With the rule gone it keeps its place, and may still leave.
ok(&owner, &["branch", "protect", &url, "main", "--undo"]);
let checked = ok(&owner, &["pull", "merge", &url, &x, "--check"]);
assert!(checked.contains(",queue,"), "{checked}");
assert!(
ok(&owner, &["pull", "merge", &url, &x, "--queue"])
.starts_with("queued into main: 1 of 1\n")
);
ok(&owner, &["branch", "protect", &url, "main", "--queue"]);
// A collaborator may too, behind it; not an issue, or a pull that is
// not open.
let mut named = owner.record("dev.knotless.repo", "queued");
named["collaborators"] = serde_json::json!([{"did": contrib.account.did}]);
owner.put_record("dev.knotless.repo", "queued", named);
let y = pull(&contrib, "y");
let queued = ok(&contrib, &["pull", "merge", &url, &y, "--queue"]);
assert!(queued.starts_with("queued into main: 2 of 2\n"), "{queued}");
let issue = ok(&owner, &["issue", "create", &url, "Not a pull"]);
listed(&owner, &url, &issue);
refused(
&owner,
&["pull", "merge", &url, &issue, "--queue"],
"is an issue",
);
// Taken out, it is not in the queue to take out again. What a
// stranger wrote counts for nothing.
wait_until(|| ok(&owner, &["queue", "list", &url]).contains(&y));
let line = ok(&owner, &["queue", "list", &url]);
assert!(
line.starts_with(&format!("1\tmain\tqueued\t{x}\tx\n2\tmain\tqueued\t{y}")),
"{line}"
);
assert_eq!(
ok(&contrib, &["queue", "leave", &url, &y]),
"left the merge queue"
);
wait_until(|| !ok(&owner, &["queue", "list", &url]).contains(&y));
refused(
&contrib,
&["queue", "leave", &url, &y],
"is not in the merge queue",
);
ok(&owner, &["pull", "close", &url, &y]);
listed(&owner, &url, &format!("pull\tclosed\t{y}"));
refused(&owner, &["pull", "merge", &url, &y, "--queue"], "is closed");
let (code, out) = owner.cli(&["queue", "list", &url, "--bogus"], b"");
assert_eq!(code, 1, "{out}");
// Ejected, as a runner says, it shows why.
owner.put_record(
"dev.knotless.queue",
&knotless::keys::tid(),
record("ejected"),
);
wait_until(|| ok(&owner, &["queue", "list", &url]).contains("ejected"));
assert_eq!(
ok(&owner, &["queue", "list", &url]),
format!("-\tmain\tejected\t{x}\tx\n\tEjected: why not")
);
// The queue reads it from its writer's repo, the thread from the
// index, which hears of it a moment after.
wait_until(|| ok(&owner, &["pull", "show", &url, &x]).contains("queue: ejected"));
let shown = ok(&owner, &["pull", "show", &url, &x]);
assert!(
shown.contains("\nmerge queue: ejected at 2026-10-21T00:00:00.000Z\nEjected: why not"),
"{shown}"
);
// One that could only be ejected is not let in: one that conflicts
// with the branch as it is, saying where, and one whose checks failed
// at its head merged with it, until they pass.
owner.git(&work, &["checkout", "-q", "-B", "clash", "main"]);
owner.commit_file(&work, "c", "theirs\n");
owner.git(&work, &["push", "-q", &url, "clash"]);
owner.git(&work, &["checkout", "-q", "main"]);
owner.commit_file(&work, "c", "ours\n");
owner.git(&work, &["push", "-q", &url, "main"]);
let clash = ok(
&owner,
&["pull", "create", &url, "main", &url, "clash", "clash"],
);
listed(&owner, &url, &clash);
let queueing = ["pull", "merge", &url, &clash, "--queue"];
refused(&owner, &queueing, "does not apply on main at ");
refused(&owner, &queueing, "it conflicts in c. Merge main into it");
let z = pull(&owner, "z");
let tip = owner.git(&work, &["rev-parse", "main"]);
let head = owner.git(&work, &["rev-parse", "z"]);
let key = key.as_str().unwrap();
let check = |status: &str, second: u8| {
let rkey = knotless::keys::check_rkey(&owner.account.did, key, &z, &head, Some(&tip), "ci");
let record = serde_json::json!({
"$type": "dev.knotless.check", "repo": repo, "ref": "refs/heads/z",
"pull": z, "commit": head, "baseCommit": tip, "workflow": "ci",
"status": status, "startedAt": format!("2026-10-21T00:01:{second:02}.000Z"),
});
contrib.put_record("dev.knotless.check", &rkey, record);
};
check("failure", 1);
let queueing = ["pull", "merge", &url, &z, "--queue"];
wait_until(|| {
owner
.knotless(&queueing, b"")
.2
.contains("did not pass its checks")
});
refused(
&owner,
&queueing,
": ci (failure). Fix it and queue it again",
);
check("success", 2);
wait_until(|| owner.knotless(&queueing, b"").0);
}
/// Merge when checks pass: set by who may merge, on a branch with no queue,
/// cancelled by them or the pull's author, and by a push from anyone who
/// may not merge it, or by retargeting it. `pull show` says which.
#[test]
fn merge_when_checks_pass_is_set_by_who_may_merge() {
let av = appview();
let owner = person(account(), &av);
let contrib = person(account(), &av);
let stranger = person(account(), &av);
let url = owner.url("auto");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let branch = |name: &str| {
owner.git(&work, &["checkout", "-q", "-B", name, "main"]);
owner.commit_file(&work, name, "x\n");
owner.git(&work, &["push", "-q", &url, name]);
};
branch("x");
let x = ok(&owner, &["pull", "create", &url, "main", &url, "x", "x"]);
let x = x.trim().to_string();
listed(&owner, &url, &x);
let refused = |who: &Sandbox, args: &[&str], why: &str| {
let (done, _, err) = who.knotless(args, b"");
assert!(!done && err.contains(why), "{args:?}: {err}");
};
let auto = ["pull", "merge", &url, &x, "--auto"];
refused(&owner, &auto, "names no runners");
ok(&owner, &["runner", "add", &url, &stranger.account.handle]);
refused(
&owner,
&["pull", "merge", &url, &x, "--undo"],
"--undo cancels --auto",
);
refused(
&owner,
&["pull", "merge", &url, &x, "--auto", "--wait", "5"],
"--auto takes none of",
);
refused(&contrib, &auto, "only the owner and collaborators");
// A branch with the queue holds a pull until its checks pass anyway.
ok(&owner, &["branch", "protect", &url, "main", "--queue"]);
refused(&owner, &auto, "takes pulls through the merge queue");
ok(&owner, &["branch", "protect", &url, "main", "--undo"]);
branch("d");
let opening = ["pull", "create", &url, "main", &url, "d", "D", "--draft"];
let d = ok(&owner, &opening);
listed(&owner, &url, d.trim());
refused(
&owner,
&["pull", "merge", &url, d.trim(), "--auto"],
"is a draft",
);
// Set, it says so, and by whom.
let set = ok(&owner, &auto);
assert!(
set.starts_with("set to merge when its checks pass, at "),
"{set}"
);
let says = |who: &Sandbox, pull: &str, what: &str| {
wait_until(|| ok(who, &["pull", "show", &url, pull]).contains(what));
};
let by = format!("merge when checks pass: set by {}", owner.account.handle);
says(&owner, &x, &by);
// A stranger may not cancel it; the owner may, once.
let undo = ["pull", "merge", &url, &x, "--auto", "--undo"];
refused(&stranger, &undo, "and the pull's author, cancel");
let cancelled = ok(&owner, &undo);
assert!(cancelled.starts_with("cancelled"), "{cancelled}");
wait_until(|| !ok(&owner, &["pull", "show", &url, &x]).contains("merge when checks pass"));
refused(&owner, &undo, "is not set to merge when its checks pass");
// Moved to another branch, it is cancelled.
branch("release");
ok(&owner, &auto);
ok(&owner, &["pull", "retarget", &url, &x, "--base", "release"]);
says(&owner, &x, "cancelled, as the pull moved off main");
// A fork's pull: its author's push cancels it, as they may not merge
// it; the author may cancel it too.
let fork = ok(&contrib, &["repo", "fork", &url]);
let fork = fork.trim();
let clone = contrib.path("clone");
contrib.git(contrib.dir.path(), &["clone", "-q", fork, "clone"]);
contrib.git(&clone, &["checkout", "-q", "-b", "f"]);
contrib.commit_file(&clone, "f", "one\n");
contrib.git(&clone, &["push", "-q", "origin", "f"]);
let f = ok(&contrib, &["pull", "create", &url, "main", fork, "f", "F"]);
let f = f.trim().to_string();
listed(&owner, &url, &f);
ok(&owner, &["pull", "merge", &url, &f, "--auto"]);
says(&contrib, &f, &format!("set by {}", owner.account.handle));
contrib.commit_file(&clone, "f", "two\n");
contrib.git(&clone, &["push", "-q", "origin", "f"]);
let pushed = format!(
"cancelled, as {} pushed after {} set it",
contrib.account.handle, owner.account.handle
);
says(&owner, &f, &pushed);
// Gauged for the pages as its push left it: cancelled.
let (_, checked, _) = owner.knotless(&["pull", "merge", &url, &f, "--check"], b"");
assert!(
!checked.contains("merges when its checks pass"),
"{checked}"
);
let undone = ok(&contrib, &["pull", "merge", &url, &f, "--auto", "--undo"]);
assert!(undone.starts_with("cancelled"), "{undone}");
wait_until(|| !ok(&owner, &["pull", "show", &url, &f]).contains("merge when checks pass"));
}
#[test]
fn assignees_and_reviews_maintainers_ask_for() {
let av = appview();
let owner = person(account(), &av);
let collab = person(account(), &av);
let author = person(account(), &av);
let url = owner.url("assigned");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
owner.git(&work, &["checkout", "-q", "-B", "a", "main"]);
owner.commit_file(&work, "a", "x\n");
owner.git(&work, &["push", "-q", &url, "a"]);
ok(
&owner,
&["account", "delegate", &owner.account.handle, &av.did],
);
ok(
&owner,
&["repo", "share", &url, &collab.account.handle, "--write"],
);
let refused = |who: &Sandbox, args: &[&str], why: &str| {
let (done, _, err) = who.knotless(args, b"");
assert!(!done && err.contains(why), "{args:?}: {err}");
};
let (mine, theirs) = (&owner.account.handle, &collab.account.handle);
let issue = ok(&author, &["issue", "create", &url, "Broken"]);
let pull = ok(
&author,
&["pull", "create", &url, "main", &url, "a", "Fix it"],
);
let comment = ok(&author, &["comment", "add", &url, &issue, "Still broken"]);
// The owner and collaborators assign, and only pulls and issues.
refused(
&owner,
&["issue", "assign", &url, &issue],
"name an account",
);
refused(
&author,
&["issue", "assign", &url, &issue, mine],
"only the owner and collaborators",
);
refused(
&owner,
&["pull", "assign", &url, &comment, mine],
"is not a pull or an issue",
);
refused(
&owner,
&["pull", "ask-review", &url, &issue, theirs],
"is not a pull",
);
// One by one: assigned at once, they stand in the order of their DIDs.
assert_eq!(
ok(&owner, &["issue", "assign", &url, &issue, theirs]),
theirs.as_str()
);
assert_eq!(
ok(&owner, &["issue", "assign", &url, &issue, mine]),
format!("{theirs}\n{mine}")
);
assert_eq!(
ok(&collab, &["pull", "assign", &url, &issue, mine, "--undo"]),
theirs.as_str()
);
wait_until(|| {
let shown = author.cli(&["issue", "show", &url, &issue], b"").1;
shown.contains(&format!("\nassignees: {theirs}\n"))
});
let found =
|who: &Sandbox, q: &str| ok(who, &["issue", "list", &url, "-q", q]).contains(&issue);
assert!(found(&collab, "assignee:@me") && !found(&owner, "assignee:@me"));
assert!(found(&owner, &format!("assignee:{theirs}")) && !found(&owner, "no:assignee"));
// Whom it is assigned to hears of it.
let heard = |who: &Sandbox| who.cli(&["notification", "list", "--peek"], b"").1;
wait_until(|| heard(&collab).contains("assigned"));
// A maintainer asks for a review besides the author, never of the
// author; the author stops asking only those it asked.
let them = author.account.handle.as_str();
refused(
&owner,
&["pull", "ask-review", &url, &pull, them],
"reviews no pull of their own",
);
assert_eq!(
ok(&owner, &["pull", "ask-review", &url, &pull, theirs]),
theirs.as_str()
);
// The author reads the owner's ask through the appview, once it
// hears of it; asking again asks no more.
let both = format!("{mine}\n{theirs}");
wait_until(|| ok(&author, &["pull", "ask-review", &url, &pull, mine]) == both);
refused(
&author,
&["pull", "ask-review", &url, &pull, theirs, "--undo"],
"asked by the owner or a collaborator",
);
refused(
&collab,
&["pull", "ask-review", &url, &pull, mine, "--undo"],
"asked by the pull's author",
);
wait_until(|| heard(&collab).contains("review-requested"));
// Each finds what concerns it, across repositories, with where it is.
let concerning = |who: &Sandbox, rest: &str| {
let did = &who.account.did;
let at = format!(
"{}/xrpc/dev.knotless.listActorItems?actor={did}&{rest}",
av.base
);
reqwest::blocking::get(at).unwrap()
};
let titles = |who: &Sandbox, rest: &str| -> Vec<String> {
let page: Value = concerning(who, rest).json().unwrap();
let items = page["items"].as_array().unwrap();
let title = |i: &Value| i["item"]["title"].as_str().unwrap().to_string();
items.iter().map(title).collect()
};
assert_eq!(titles(&author, "kind=issue"), ["Broken"]);
assert_eq!(titles(&author, "kind=pull"), ["Fix it"]);
assert!(titles(&author, "kind=issue&state=closed").is_empty());
assert!(titles(&collab, "kind=issue").is_empty());
assert_eq!(titles(&collab, "kind=issue&relation=assigned"), ["Broken"]);
assert_eq!(titles(&collab, "kind=pull&relation=review"), ["Fix it"]);
let page: Value = concerning(&collab, "kind=pull&relation=review")
.json()
.unwrap();
assert_eq!(page["items"][0]["repo"]["name"], "assigned");
assert_eq!(page["items"][0]["repo"]["did"], owner.account.did.as_str());
assert_eq!(page["items"][0]["repo"]["handle"], mine.as_str());
assert!(page["cursor"].is_null());
// Across repositories, newest first, a page at a time.
let another = owner.url("assigned-too");
ok(&owner, &["repo", "create", &another]);
ok(&author, &["issue", "create", &another, "Also broken"]);
wait_until(|| titles(&author, "kind=issue").len() == 2);
assert_eq!(titles(&author, "kind=issue"), ["Also broken", "Broken"]);
let first: Value = concerning(&author, "kind=issue&limit=1").json().unwrap();
assert_eq!(first["items"][0]["item"]["title"], "Also broken");
assert_eq!(first["cursor"], "1");
assert_eq!(titles(&author, "kind=issue&limit=1&cursor=1"), ["Broken"]);
assert_eq!(concerning(&author, "kind=star").status(), 400);
assert_eq!(concerning(&author, "kind=pull&cursor=x").status(), 400);
let unknown = format!(
"{}/xrpc/dev.knotless.listActorItems?actor=nobody.invalid&kind=pull",
av.base
);
assert_eq!(reqwest::blocking::get(unknown).unwrap().status(), 400);
assert_eq!(
ok(
&collab,
&["pull", "ask-review", &url, &pull, theirs, "--undo"]
),
mine.as_str()
);
assert_eq!(
ok(
&author,
&["pull", "ask-review", &url, &pull, mine, "--undo"]
),
""
);
// An owner's own pull asks in itself, and stopping stops their
// fellow maintainers' asks too.
let own = ok(&owner, &["pull", "create", &url, "main", &url, "a", "Mine"]);
assert_eq!(
ok(&collab, &["pull", "ask-review", &url, &own, theirs]),
theirs.as_str()
);
assert_eq!(
ok(&owner, &["pull", "ask-review", &url, &own, theirs]),
theirs.as_str()
);
assert_eq!(
ok(
&owner,
&["pull", "ask-review", &url, &own, theirs, "--undo"]
),
""
);
}
#[test]
fn maintainers_dismiss_reviews() {
let av = appview();
let owner = person(account(), &av);
let collab = person(account(), &av);
let bot = person(account(), &av);
let url = owner.url("dismissed");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
owner.git(&work, &["checkout", "-q", "-B", "a", "main"]);
owner.commit_file(&work, "a", "x\n");
owner.git(&work, &["push", "-q", &url, "a"]);
ok(
&owner,
&["repo", "share", &url, &collab.account.handle, "--write"],
);
let gate = bot.account.handle.as_str();
ok(&owner, &["branch", "protect", &url, "main", "--gate", gate]);
let refused = |who: &Sandbox, args: &[&str], why: &str| {
let (done, _, err) = who.knotless(args, b"");
assert!(!done && err.contains(why), "{args:?}: {err}");
};
// The gate asks for changes to the owner's own pull, whose approval
// would not count.
let pull = ok(&owner, &["pull", "create", &url, "main", &url, "a", "Mine"]);
let issue = ok(&owner, &["issue", "create", &url, "Broken"]);
// The gate is asked of it as its required reviewer, and stays asked.
let shown = ok(&collab, &["pull", "show", &url, &pull]);
assert!(
shown.contains(&format!("review requested: {gate}")),
"{shown}"
);
refused(
&owner,
&["pull", "ask-review", &url, &pull, gate, "--undo"],
"is a required reviewer for main",
);
// The pages mark it so.
let listed = reqwest::Url::parse_with_params(
&format!("{}/xrpc/dev.knotless.listItems", av.base),
[
(
"repo",
format!("at://{}/dev.knotless.repo/dismissed", owner.account.did).as_str(),
),
("kind", "pull"),
],
);
let listed = listed.unwrap();
let required = || -> Value {
let page: Value = reqwest::blocking::get(listed.clone())
.unwrap()
.json()
.unwrap();
page["items"][0]["requiredReviewers"][0]["did"].clone()
};
wait_until(|| required() == bot.account.did.as_str());
ok(
&bot,
&["comment", "add", &url, &pull, "Not yet", "--changes"],
);
let blocked = |who: &Sandbox| {
let checked = ok(who, &["pull", "merge", &url, &pull, "--check"]);
checked.contains("reviews blocked")
};
wait_until(|| blocked(&owner));
let theirs = collab.account.handle.as_str();
refused(
&owner,
&["pull", "dismiss-review", &url, &pull, gate, " "],
"say why",
);
refused(
&owner,
&["pull", "dismiss-review", &url, &issue, gate, "No"],
"is not a pull",
);
refused(
&bot,
&["pull", "dismiss-review", &url, &pull, gate, "No"],
"only the owner and collaborators",
);
refused(
&owner,
&["pull", "dismiss-review", &url, &pull, theirs, "No"],
"has no standing review",
);
// The owner dismisses it, saying why: it counts for nothing, and the
// gate holds the pull no more.
let dismissal = ok(
&owner,
&[
"pull",
"dismiss-review",
&url,
&pull,
gate,
"Fixed in review",
],
);
assert!(dismissal.contains("/dev.knotless.comment/"), "{dismissal}");
// Another reads the dismissal once the appview hears of it.
let show = || ok(&collab, &["pull", "show", &url, &pull]);
wait_until(|| show().contains(", dismissed\n"));
let shown = show();
assert!(
shown.contains(&format!("review: {gate} changes at ")),
"{shown}"
);
assert!(!blocked(&collab));
refused(
&collab,
&["pull", "dismiss-review", &url, &pull, gate, "Again"],
"has no standing review",
);
// Its next review stands again, once the appview hears of it.
let review = ok(
&bot,
&["comment", "add", &url, &pull, "Still not", "--changes"],
);
wait_until(|| blocked(&owner));
// Named by the review's own URI, as well as by its reviewer.
let review = review.lines().next().unwrap();
ok(
&collab,
&["pull", "dismiss-review", &url, &pull, review, "Fine by me"],
);
assert!(!blocked(&owner));
}
#[test]
fn drafts_and_requested_reviewers() {
let av = appview();
let owner = person(account(), &av);
let contrib = person(account(), &av);
let reviewer = person(account(), &av);
let url = owner.url("drafts");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
owner.git(&work, &["checkout", "-q", "-B", "d", "main"]);
owner.commit_file(&work, "d", "x\n");
owner.git(&work, &["push", "-q", &url, "d"]);
let refused = |who: &Sandbox, args: &[&str], why: &str| {
let (done, _, err) = who.knotless(args, b"");
assert!(!done && err.contains(why), "{args:?}: {err}");
};
let (me, them) = (&contrib.account.handle, &reviewer.account.handle);
let opening = [
"pull",
"create",
&url,
"main",
&url,
"d",
"D",
"--reviewer",
me,
];
refused(&contrib, &opening, "reviews no pull of their own");
refused(
&contrib,
&["issue", "create", &url, "I", "--draft"],
"unexpected --draft",
);
// Opened as a draft, asking for a review, it neither merges nor
// queues, and says so.
let opening = [
"pull",
"create",
&url,
"main",
&url,
"d",
"D",
"--draft",
"--reviewer",
them,
];
let pull = ok(&contrib, &opening);
listed(&owner, &url, &pull);
let shown = ok(&owner, &["pull", "show", &url, &pull]);
assert!(shown.contains("\ndraft\nreview requested: "), "{shown}");
refused(
&owner,
&["pull", "merge", &url, &pull, "--force"],
"is a draft",
);
let checked = ok(&owner, &["pull", "merge", &url, &pull, "--check"]);
assert!(checked.starts_with("draft\t"), "{checked}");
assert!(checked.contains("--ready`"), "{checked}");
ok(&owner, &["runner", "add", &url, me]);
refused(
&owner,
&["pull", "merge", &url, &pull, "--queue"],
"is a draft",
);
// The list filters on both.
let found = |who: &Sandbox, q: &str| ok(who, &["pull", "list", &url, "-q", q]).contains(&pull);
assert!(found(&owner, "is:draft") && !found(&owner, "-is:draft"));
assert!(found(&reviewer, "review-requested:@me"));
assert!(!found(&owner, "review-requested:@me"));
assert!(found(&owner, &format!("review-requested:{them}")));
let did = &reviewer.account.did;
assert!(found(&owner, &format!("review-requested:{did}")));
assert!(!found(&owner, "review-requested:nobody.invalid"));
// Asked with no one logged in, `@me` is no one.
let anyone = |q: &str| -> Value {
let repo = format!("at://{}/dev.knotless.repo/drafts", owner.account.handle);
let at = reqwest::Url::parse_with_params(
&format!("{}/xrpc/dev.knotless.listItems", av.base),
[("repo", repo.as_str()), ("kind", "pull"), ("q", q)],
);
reqwest::blocking::get(at.unwrap()).unwrap().json().unwrap()
};
assert_eq!(
anyone("review-requested:@me")["items"],
serde_json::json!([])
);
let item = &anyone("is:draft")["items"][0];
assert_eq!(item["draft"], true);
assert_eq!(item["reviewers"][0]["did"], did.as_str());
// Its reviewer hears of it once it is ready, not before.
let heard = || reviewer.cli(&["notification", "list", "--peek"], b"").1;
assert!(!heard().contains("review-requested"));
assert_eq!(
ok(&contrib, &["pull", "edit", &url, &pull, "--ready"]),
"edited"
);
wait_until(|| heard().contains("review-requested"));
wait_until(|| !found(&owner, "is:draft"));
// A draft again, asking someone else instead.
let owner_handle = owner.account.handle.as_str();
// Asking again asks once.
let editing = [
"pull",
"edit",
&url,
&pull,
"--draft",
"--reviewer",
owner_handle,
"--reviewer",
owner_handle,
"--no-reviewer",
them,
];
ok(&contrib, &editing);
wait_until(|| found(&owner, "review-requested:@me") && found(&owner, "is:draft"));
assert!(!found(&reviewer, "review-requested:@me"));
let asked = ok(&owner, &["pull", "show", &url, &pull]);
let asked = asked.lines().find(|l| l.starts_with("review requested: "));
assert_eq!(asked.map(|l| l.matches(',').count()), Some(0), "{asked:?}");
// Only a pull's author changes it, and only a pull is a draft.
refused(
&owner,
&["pull", "edit", &url, &pull, "--ready"],
"only the author",
);
let issue = ok(&contrib, &["issue", "create", &url, "An issue"]);
refused(
&contrib,
&["issue", "edit", &url, &issue, "--draft"],
"is not a pull",
);
refused(
&contrib,
&["pull", "edit", &url, &pull],
"nothing to change",
);
}
#[test]
fn queue_only_branches_take_pulls_through_the_queue() {
let av = appview();
let owner = person(account(), &av);
let ci = person(account(), &av);
let url = owner.url("lined");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let pull = |branch: &str| {
owner.git(&work, &["checkout", "-q", "-B", branch, "main"]);
owner.commit_file(&work, branch, "x\n");
owner.git(&work, &["push", "-q", &url, branch]);
let pull = ok(
&owner,
&["pull", "create", &url, "main", &url, branch, branch],
);
listed(&owner, &url, &pull);
pull
};
let x = pull("x");
// Set, it is one of the rule's, and read back.
assert_eq!(ok(&owner, &["branch", "rules", &url]), "");
fails(
&owner,
&["branch", "protect", &url, "main", "--queue", "--undo"],
);
let protected = ok(&owner, &["branch", "protect", &url, "main", "--queue"]);
assert_eq!(protected, "main\tlinear\tqueue");
assert_eq!(
ok(&owner, &["branch", "rules", &url]),
"main\tlinear\tqueue"
);
let rules = owner.record("dev.knotless.repo", "lined")["rules"].clone();
assert_eq!(
rules,
serde_json::json!([{"branch": "main", "history": "linear", "queue": true}])
);
// With no runner to land the queue, a merge says so.
let (done, _, err) = owner.knotless(&["pull", "merge", &url, &x], b"");
assert!(
!done && err.contains("names no runners to land it"),
"{err}"
);
ok(&owner, &["runner", "add", &url, &ci.account.handle]);
let checked = ok(&owner, &["pull", "merge", &url, &x, "--check"]);
assert!(checked.contains("\nqueue only"), "{checked}");
// A plain merge queues it instead, and says so.
let queued = ok(&owner, &["pull", "merge", &url, &x]);
assert!(
queued.starts_with("main takes pulls through the merge queue: queued, 1 of 1\n"),
"{queued}"
);
wait_until(|| ok(&owner, &["queue", "list", &url]).contains(&x));
// The owner's --force merges it at once.
let merged = ok(&owner, &["pull", "merge", &url, &x, "--force"]);
assert!(merged.ends_with("deleted x"), "{merged}");
listed(&owner, &url, &format!("pull\tmerged\t{x}"));
// Protected again without it, a merge merges, as before.
assert_eq!(
ok(&owner, &["branch", "protect", &url, "main"]),
"main\tlinear"
);
let y = pull("y");
assert!(!ok(&owner, &["pull", "merge", &url, &y, "--check"]).contains("queue only"));
let merged = ok(&owner, &["pull", "merge", &url, &y]);
assert!(merged.ends_with("deleted y"), "{merged}");
}
#[test]
fn invites_reach_their_runner() {
let av = appview_following(false, "");
let owner = person(account(), &av);
let runner = person(account(), &av);
let (o, r) = (ctx(&owner), ctx(&runner));
let me = |c: &knotless::env::Ctx| av.rt.block_on(knotless::session::me(c)).unwrap();
let (mine, its) = (me(&o), me(&r));
let invite = |name: &str| {
let sent = knotless::invites::invite(&o, &mine, &runner.account.did, name);
av.rt.block_on(sent).unwrap();
};
let list = |cursor: Option<&str>| {
let page = av.rt.block_on(knotless::invites::list(&r, &its, cursor));
let page = page.unwrap();
let names: Vec<String> = page.invites.iter().map(|i| i.name.clone()).collect();
assert!(page.invites.iter().all(|i| i.did == owner.account.did));
(names, page.cursor.unwrap())
};
invite("a");
invite("b");
// Invited again, it moves to the end.
invite("a");
let (names, cursor) = list(None);
assert_eq!(names, ["b", "a"]);
// From where it ended, nothing more until another, and the cursor
// stays.
assert_eq!(list(Some(&cursor)), (vec![], cursor.clone()));
invite("c");
assert_eq!(list(Some(&cursor)).0, ["c"]);
// The owner has none of its own.
let theirs = av.rt.block_on(knotless::invites::list(&o, &mine, None));
assert!(theirs.unwrap().invites.is_empty());
// Each asks with a token for its own method, and for a runner by DID,
// a repository by a name one could have.
let client = reqwest::blocking::Client::new();
let token = |c: &knotless::env::Ctx, who: &knotless::session::Session, lxm: &str| {
let token = knotless::auth::service_token(c, who, &av.did, lxm);
format!("Bearer {}", av.rt.block_on(token).unwrap())
};
let inviting = token(&o, &mine, knotless::invites::INVITE);
let post = |body: Value| {
let url = format!("{}/xrpc/{}", av.base, knotless::invites::INVITE);
let req = client.post(url).header("authorization", &inviting);
req.json(&body).send().unwrap().status().as_u16()
};
let to = runner.account.did.as_str();
assert_eq!(
post(serde_json::json!({"runner": "someone", "name": "a"})),
400
);
assert_eq!(post(serde_json::json!({"runner": to, "name": "a/b"})), 400);
let listing = token(&r, &its, knotless::invites::LIST);
let get = |query: &str, auth: &str| {
let url = format!("{}/xrpc/{}{query}", av.base, knotless::invites::LIST);
let req = client.get(url).header("authorization", auth);
req.send().unwrap().status().as_u16()
};
assert_eq!(get("?cursor=x", &listing), 400);
assert_eq!(get("?limit=1", &inviting), 401);
}
#[test]
fn secrets_reach_only_their_runner() {
use knotless::secrets::{self, Secret};
let av = appview_following(false, "");
let owner = person(account(), &av);
let runner = person(account(), &av);
let stranger = person(account(), &av);
let (o, r, x) = (ctx(&owner), ctx(&runner), ctx(&stranger));
let me = |c: &knotless::env::Ctx| av.rt.block_on(knotless::session::me(c)).unwrap();
let (mine, its, theirs) = (me(&o), me(&r), me(&x));
let (od, rd) = (owner.account.did.clone(), runner.account.did.clone());
let xd = stranger.account.did.clone();
let secret = |name: &str, runner: &str, refs: &[&str], sealed: &[u8]| Secret {
name: name.into(),
runner: runner.into(),
refs: refs.iter().map(|r| r.to_string()).collect(),
key: "did:key:zDnaeTest".into(),
sealed: Some(sealed.to_vec()),
updated_at: String::new(),
};
let put = |s: Secret| {
av.rt
.block_on(secrets::put(&o, &mine, &od, "3kabc", &s))
.unwrap()
};
put(secret("TOKEN", &rd, &[], b"old"));
// Put again under the same name and runner, it is replaced.
put(secret("TOKEN", &rd, &["refs/heads/main"], b"sealed"));
put(secret(
"TOKEN",
"did:web:elsewhere.example",
&[],
b"elsewhere",
));
put(secret("DEPLOY", &rd, &[], b"deploy"));
let list = |c, who, owner: &str| {
let listed = av.rt.block_on(secrets::list(c, who, owner, "3kabc"));
listed.unwrap()
};
// Its owner sees every one, by name, and never a value.
let all = list(&o, &mine, &od);
let names: Vec<_> = all
.iter()
.map(|s| (s.name.as_str(), s.runner.as_str()))
.collect();
assert_eq!(
names,
[
("DEPLOY", rd.as_str()),
("TOKEN", rd.as_str()),
("TOKEN", "did:web:elsewhere.example")
]
);
assert!(all.iter().all(|s| s.sealed.is_none()));
// A DID of the PLC sorts before one of the web.
assert_eq!(all[1].refs, ["refs/heads/main"]);
assert!(all[1].updated_at.ends_with('Z'), "{all:?}");
// The runner sees those sealed to it, with their values; anyone else,
// none.
let own = list(&r, &its, &od);
let sealed: Vec<_> = own.iter().map(|s| s.sealed.clone().unwrap()).collect();
assert_eq!(sealed, [b"deploy".to_vec(), b"sealed".to_vec()]);
assert_eq!(own[1].key, "did:key:zDnaeTest");
assert!(list(&x, &theirs, &od).is_empty());
// A runner that logs in as the owner sees the values sealed to it, and
// still no other.
put(secret("SELF", &od, &[], b"self"));
let all = list(&o, &mine, &od);
let values: Vec<_> = all
.iter()
.map(|s| (s.name.as_str(), s.sealed.clone()))
.collect();
assert_eq!(
values,
[
("DEPLOY", None),
("SELF", Some(b"self".to_vec())),
("TOKEN", None),
("TOKEN", None)
]
);
let self_gone = secrets::delete(&o, &mine, &od, "3kabc", Some("SELF"), None);
assert_eq!(av.rt.block_on(self_gone).unwrap(), 1);
// Deleted by name, for every runner; by runner; then the rest.
let delete = |name: Option<&str>, runner: Option<&str>| {
let done = secrets::delete(&o, &mine, &od, "3kabc", name, runner);
av.rt.block_on(done).unwrap()
};
assert_eq!(delete(Some("TOKEN"), Some("did:web:elsewhere.example")), 1);
assert_eq!(delete(Some("TOKEN"), None), 1);
put(secret("TOKEN", &rd, &[], b"again"));
assert_eq!(delete(None, Some(&rd)), 2);
put(secret("TOKEN", &rd, &[], b"again"));
assert_eq!(delete(None, None), 1);
assert!(list(&o, &mine, &od).is_empty());
// A stranger's delete deletes only its own, which it has none of.
let theirs_gone = secrets::delete(&x, &theirs, &xd, "3kabc", None, None);
assert_eq!(av.rt.block_on(theirs_gone).unwrap(), 0);
// Naming the owner, it is refused: the owner is no organization it
// administers.
let not_theirs = secrets::delete(&x, &theirs, &od, "3kabc", None, None);
let refused = av.rt.block_on(not_theirs).unwrap_err().to_string();
assert!(refused.contains("is not an admin of"), "{refused}");
let other = secret("T", &rd, &[], b"s");
let not_put = secrets::put(&x, &theirs, &od, "3kabc", &other);
assert!(av.rt.block_on(not_put).is_err());
// What a put must be, each refused.
let client = reqwest::blocking::Client::new();
let token = |lxm: &str| {
let token = knotless::auth::service_token(&o, &mine, &av.did, lxm);
format!("Bearer {}", av.rt.block_on(token).unwrap())
};
let putting = token(secrets::PUT);
let post = |body: Value| {
let url = format!("{}/xrpc/{}", av.base, secrets::PUT);
let req = client.post(url).header("authorization", &putting);
req.json(&body).send().unwrap().status().as_u16()
};
let good = serde_json::json!({
"repo": "3kabc", "runner": rd, "name": "TOKEN",
"key": "did:key:zDnaeTest", "sealed": {"$bytes": "c2VhbGVk"},
});
let with = |field: &str, value: Value| {
let mut body = good.clone();
body[field] = value;
body
};
let many: Vec<Value> = (0..17)
.map(|_| serde_json::json!({"pattern": "refs/heads/x"}))
.collect();
let big = "A".repeat(90_000);
for bad in [
with("repo", "a/b".into()),
with("runner", "someone".into()),
with("key", "did:plc:notakey".into()),
with("name", "KNOTLESS_RUN_X".into()),
with("refs", many.into()),
with("refs", serde_json::json!([{"pattern": ""}])),
with("sealed", serde_json::json!({"$bytes": ""})),
with("sealed", serde_json::json!({"$bytes": big})),
] {
assert_eq!(post(bad.clone()), 400, "{bad}");
}
assert_eq!(post(good), 200);
// Each method takes a token for itself.
let url = format!("{}/xrpc/{}?owner={od}&repo=3kabc", av.base, secrets::LIST);
let listed = client
.get(url)
.header("authorization", &putting)
.send()
.unwrap();
assert_eq!(listed.status().as_u16(), 401);
}
#[test]
fn owners_seal_secrets_to_their_runners() {
use knotless::sealed::{Bound, KEY_NSID, KEY_RKEY, RunnerKeys};
let av = appview_following(false, "");
let owner = person(account(), &av);
let runner = person(account(), &av);
let late = person(account(), &av);
let (od, rd) = (owner.account.did.clone(), runner.account.did.clone());
let url = owner.url("sealed");
ok(&owner, &["repo", "create", &url]);
let set = |args: &[&str], value: &[u8]| {
let all = [&["secret", "set", &url][..], args].concat();
owner.cli(&all, value)
};
// Naming no runner, there is no one to seal it to.
assert_eq!(set(&["TOKEN"], b"s3cret\n").0, 1);
// Nor to a runner that publishes no key.
ok(&owner, &["runner", "add", &url, &runner.account.handle]);
assert_eq!(set(&["TOKEN"], b"s3cret\n").0, 1);
// Nor to one whose key is no did:key.
let bad = serde_json::json!({"$type": KEY_NSID, "key": "nope", "createdAt": "2026-10-29T00:00:00.000Z"});
runner.put_record(KEY_NSID, KEY_RKEY, bad);
assert_eq!(set(&["TOKEN"], b"s3cret\n").0, 1);
let dir = tempfile::tempdir().unwrap();
let keys = RunnerKeys::open_or_make(&dir.path().join("key.json")).unwrap();
let published = serde_json::json!({
"$type": KEY_NSID,
"key": keys.public().did_key(),
"createdAt": "2026-10-29T00:00:00.000Z",
});
runner.put_record(KEY_NSID, KEY_RKEY, published);
let sealed = format!("sealed TOKEN to {rd}");
assert_eq!(
set(&["TOKEN", "--ref", "refs/heads/main"], b"s3cret\r\n"),
(0, sealed)
);
assert_eq!(set(&["DEPLOY"], b"d").0, 0);
let listed = ok(&owner, &["secret", "list", &url]);
let lines: Vec<Vec<&str>> = listed.lines().map(|l| l.split('\t').collect()).collect();
assert_eq!(lines[0][..3], ["DEPLOY", rd.as_str(), "*"], "{listed}");
assert_eq!(lines[1][..3], ["TOKEN", rd.as_str(), "refs/heads/main"]);
// Only the runner opens it, and without the line ending stdin gave it.
let key = owner.record("dev.knotless.repo", "sealed")["key"]
.as_str()
.unwrap()
.to_string();
let r = ctx(&runner);
let its = av.rt.block_on(knotless::session::me(&r)).unwrap();
let left = knotless::secrets::list(&r, &its, &od, &key);
let left = av.rt.block_on(left).unwrap();
let token = left.iter().find(|s| s.name == "TOKEN").unwrap();
let bound = Bound {
owner: &od,
repo: &key,
runner: &rd,
name: "TOKEN",
refs: &token.refs,
};
let opened = keys.open(&token.key, &bound, token.sealed.as_deref().unwrap());
assert_eq!(opened.unwrap(), b"s3cret");
// A runner named later has none of those set before, and the owner is
// told so; one leaving takes its copies with it.
let added = ok(&owner, &["runner", "add", &url, &late.account.handle]);
assert!(
added.ends_with("gets the secrets set before only once each is set again: DEPLOY, TOKEN"),
"{added}"
);
// Given to some of its runners alone, it is sealed to those, and a copy
// another had is taken back; one not among them is refused.
let theirs = RunnerKeys::open_or_make(&dir.path().join("late.json")).unwrap();
let published = serde_json::json!({
"$type": KEY_NSID,
"key": theirs.public().did_key(),
"createdAt": "2026-10-29T00:00:00.000Z",
});
late.put_record(KEY_NSID, KEY_RKEY, published);
assert_eq!(set(&["SHARED"], b"s").0, 0);
let copies = || {
let listed = ok(&owner, &["secret", "list", &url]);
let shared = listed.lines().filter(|l| l.starts_with("SHARED\t"));
shared.count()
};
assert_eq!(copies(), 2);
let only = ["SHARED", "--runner", runner.account.handle.as_str()];
assert_eq!(set(&only, b"s"), (0, format!("sealed SHARED to {rd}")));
assert_eq!(copies(), 1);
let stranger = ["SHARED", "--runner", owner.account.handle.as_str()];
assert_eq!(set(&stranger, b"s").0, 1);
assert_eq!(set(&["SHARED", "--runner"], b"s").0, 1);
ok(&owner, &["secret", "remove", &url, "SHARED"]);
ok(&owner, &["runner", "remove", &url, &late.account.handle]);
assert_eq!(
ok(&owner, &["secret", "remove", &url, "DEPLOY"]),
"removed DEPLOY"
);
fails(&owner, &["secret", "remove", &url, "DEPLOY"]);
ok(&owner, &["runner", "remove", &url, &runner.account.handle]);
assert_eq!(ok(&owner, &["secret", "list", &url]), "");
// What a secret must be, refused before anything is sealed.
let many: Vec<&str> = (0..17).flat_map(|_| ["--ref", "refs/heads/x"]).collect();
for (args, value) in [
(vec!["KNOTLESS_RUN_X"], &b"v"[..]),
(vec!["TOKEN", "--nope"], &b"v"[..]),
([&["TOKEN"][..], &many].concat(), &b"v"[..]),
(vec!["TOKEN"], &[b'x'; 20_000][..]),
] {
assert_eq!(set(&args, value).0, 1, "{args:?}");
}
fails(&owner, &["secret"]);
fails(&owner, &["secret", "list", &owner.url("missing")]);
fails(&owner, &["secret", "show", &url]);
// With no appview, there is nowhere to leave one.
assert_eq!(Sandbox::new().cli(&["secret", "list", &url], b"").0, 1);
}
#[test]
fn accounts_away_and_gone() {
let av = appview();
let leaving = person(account(), &av);
let staying = person(account(), &av);
let did = leaving.account.did.clone();
let index = av.app.index.clone().unwrap();
let client = reqwest::blocking::Client::new();
// A method that takes no input refuses a body, even an empty one.
let pds = |method: &str, auth: &str, body: Option<Value>| {
let url = format!("{}/xrpc/{method}", net().pds);
let mut req = client.post(url).header("authorization", auth);
if let Some(body) = &body {
req = req.json(body);
}
let res = req.send().unwrap();
let status = res.status();
assert!(status.is_success(), "{method}: {}", res.text().unwrap());
};
let session: Value = client
.post(format!(
"{}/xrpc/com.atproto.server.createSession",
net().pds
))
.json(&serde_json::json!({"identifier": did, "password": leaving.account.password}))
.send()
.and_then(|r| r.json())
.unwrap();
let bearer = format!("Bearer {}", session["accessJwt"].as_str().unwrap());
// A repository of its own, read here, and an issue on another's.
let url = leaving.url("leaving");
leaving.git(&leaving.work("w", 1), &["push", "-q", &url, "main"]);
let elsewhere = staying.url("staying");
staying.git(&staying.work("w", 1), &["push", "-q", &elsewhere, "main"]);
let issue = ok(&leaving, &["issue", "create", &elsewhere, "Before I go"]);
let theirs = ok(&staying, &["issue", "create", &url, "Where are you going"]);
let key_of = |uri: &str| {
wait_until(|| index.repo(uri).unwrap().is_some());
let repo = index.repo(uri).unwrap().unwrap();
repo["key"].as_str().unwrap().to_string()
};
let repo = format!("at://{did}/dev.knotless.repo/leaving");
let key = key_of(&repo);
let other = &staying.account.did;
let other_key = key_of(&format!("at://{other}/dev.knotless.repo/staying"));
let issues = |owner: &str, key: &str| -> Vec<String> {
let found = index.forge(owner, key).unwrap();
found.into_iter().map(|e| e.uri).collect()
};
wait_until(|| issues(other, &other_key) == [issue.clone()]);
wait_until(|| issues(&did, &key) == [theirs.clone()]);
let refs = format!("{}/api/{did}/leaving/refs", av.base);
let read = || reqwest::blocking::get(&refs).unwrap().status().as_u16();
assert_eq!(read(), 200);
let cache = &av.app.ctx.env.cache_dir;
let mirrors = cache.join("mirrors").join(did.replace(':', "_"));
assert!(mirrors.exists());
// Deactivated, it is hidden, and back when it is active again.
let none = serde_json::json!({});
pds("com.atproto.server.deactivateAccount", &bearer, Some(none));
wait_until(|| index.status(&did).unwrap().as_deref() == Some("deactivated"));
assert!(issues(other, &other_key).is_empty());
assert_ne!(read(), 200);
pds("com.atproto.server.activateAccount", &bearer, None);
wait_until(|| index.status(&did).unwrap().is_none());
assert_eq!(issues(other, &other_key), std::slice::from_ref(&issue));
// Said to be deleted by a host that still has it, it stays; one whose
// identity cannot be found is gone at its word.
let said = |did: &str| knotless_serve::firehose::Event {
did: Some(did.into()),
account: Some("deleted".into()),
..Default::default()
};
av.rt.block_on(async { av.app.observe(said(&did)) });
av.rt
.block_on(async { av.app.observe(said("did:plc:neverwas")) });
std::thread::sleep(Duration::from_secs(1));
assert!(index.repo(&repo).unwrap().is_some());
// Deleted, everything indexed from it goes, and its mirrors; what
// others wrote about it stays theirs.
let admin = format!("Basic {}", admin());
let deleted = serde_json::json!({"did": did});
pds("com.atproto.admin.deleteAccount", &admin, Some(deleted));
wait_until(|| index.repo(&repo).unwrap().is_none());
assert!(issues(other, &other_key).is_empty());
assert_eq!(issues(&did, &key), [theirs]);
wait_until(|| !mirrors.exists());
}
/// A repository deleted takes everything of it with it: the records keyed
/// by it and what its owner wrote about it, from the owner's account, and
/// what the appview kept of it, its mirror among them. What others wrote
/// stays in their accounts. One renamed keeps all it had.
#[test]
fn a_deleted_repository_leaves_nothing() {
let av = appview();
let owner = person(account(), &av);
let other = person(account(), &av);
let did = owner.account.did.clone();
let index = av.app.index.clone().unwrap();
let url = owner.url("gone");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let kept = owner.url("kept");
owner.git(&work, &["push", "-q", &kept, "main"]);
// What the owner wrote of it, and what another account did.
let mine = ok(&owner, &["issue", "create", &url, "Mine"]);
ok(&owner, &["comment", "add", &url, &mine, "A note"]);
ok(&owner, &["pull", "react", &url, &mine, "heart"]);
ok(&owner, &["repo", "star", &url]);
ok(&owner, &["runner", "add", &url, &owner.account.handle]);
ok(&owner, &["run", "rerun", &url, "--ref", "refs/heads/main"]);
let file = owner.path("notes.txt");
std::fs::write(&file, "notes\n").unwrap();
let file = file.to_string_lossy().to_string();
ok(
&owner,
&["release", "publish", &url, "notes", "0.1.0", &file],
);
let theirs = ok(&other, &["issue", "create", &url, "Theirs"]);
ok(&other, &["comment", "add", &url, &theirs, "Their note"]);
let key_of = |name: &str| {
let uri = format!("at://{did}/dev.knotless.repo/{name}");
wait_until(|| index.repo(&uri).unwrap().is_some());
index.repo(&uri).unwrap().unwrap()["key"]
.as_str()
.unwrap()
.to_string()
};
let (key, kept_key) = (key_of("gone"), key_of("kept"));
let said = |key: &str| index.forge(&did, key).unwrap().len();
wait_until(|| said(&key) == 4);
ok(&other, &["issue", "create", &kept, "On the kept one"]);
wait_until(|| said(&kept_key) == 1);
// Read through the appview, so that it mirrors it.
let refs = format!("{}/api/{did}/gone/refs", av.base);
assert_eq!(reqwest::blocking::get(&refs).unwrap().status(), 200);
let mirror = av.app.ctx.env.cache_dir.join("mirrors");
let mirror = mirror
.join(did.replace(':', "_"))
.join(format!("{key}.git"));
assert!(mirror.exists());
// Renamed, it is the same repository, and keeps what it had.
ok(&owner, &["repo", "rename", &kept, "still"]);
wait_until(|| {
index
.repo(&format!("at://{did}/dev.knotless.repo/still"))
.unwrap()
.is_some()
});
std::thread::sleep(Duration::from_secs(1));
assert_eq!(said(&kept_key), 1);
// Deleted, nothing in the owner's account names it any more.
ok(&owner, &["repo", "delete", &url, "--yes"]);
let keyed = |collection: &str| {
let all = owner.records(collection).into_iter();
all.filter(|(rkey, _)| rkey.starts_with(&key)).count()
};
for collection in ["ref", "claim", "pack", "release", "rerun"] {
assert_eq!(
keyed(&format!("dev.knotless.{collection}")),
0,
"{collection}"
);
}
for collection in ["issue", "comment", "reaction", "star"] {
let left = owner.records(&format!("dev.knotless.{collection}"));
assert!(left.is_empty(), "{collection}: {left:?}");
}
assert_eq!(other.records("dev.knotless.issue").len(), 2);
// The appview keeps nothing of it, what others wrote on it included,
// nor its mirror; the other repository keeps its own.
wait_until(|| said(&key) == 0 && !mirror.exists());
assert_eq!(said(&kept_key), 1);
let recent = index.recent(50, None, None, None, None, false).unwrap();
assert!(recent.iter().all(|(_, _, k)| k != &key));
fails(&owner, &["repo", "refs", &url]);
}
/// The reference PDS's administrator, as dev-env makes it, for basic auth.
fn admin() -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode("admin:admin-pass")
}