chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
//! The MCP server against a reference PDS, driven by an MCP client.
#[path = "../../knotless/tests/common/mod.rs"]
mod common;
use common::*;
use rmcp::ServiceExt;
use rmcp::model::CallToolRequestParams;
use serde_json::{Value, json};
struct Client {
rt: tokio::runtime::Runtime,
peer: rmcp::service::RunningService<rmcp::RoleClient, ()>,
}
impl Client {
fn new(s: &Sandbox) -> Client {
let rt = tokio::runtime::Runtime::new().unwrap();
let ctx = ctx(s);
let peer = rt.block_on(async {
let (server_io, client_io) = tokio::io::duplex(1 << 20);
tokio::spawn(async move {
let running = knotless_mcp::Server::new(ctx)
.serve(server_io)
.await
.unwrap();
let _ = running.waiting().await;
});
().serve(client_io).await.unwrap()
});
Client { rt, peer }
}
/// The tool's text, and whether it was an error.
fn call(&self, tool: &str, args: Value) -> (String, bool) {
let params = CallToolRequestParams::new(tool.to_string())
.with_arguments(args.as_object().unwrap().clone());
let res = self.rt.block_on(self.peer.call_tool(params)).unwrap();
let text = res.content[0].as_text().unwrap().text.clone();
(text, res.is_error == Some(true))
}
fn ok(&self, tool: &str, args: Value) -> String {
let (text, err) = self.call(tool, args);
assert!(!err, "{tool}: {text}");
text
}
fn json(&self, tool: &str, args: Value) -> Value {
serde_json::from_str(&self.ok(tool, args)).unwrap()
}
}
/// Logged in as its account, as `knotless login` leaves a machine.
fn logged_in() -> Sandbox {
let mut s = Sandbox::new();
s.set("KNOTLESS_PASSWORD", "");
let password = s.account.password.clone();
let handle = s.account.handle.clone();
assert_eq!(
s.cli(&["login", &handle, "--password-stdin"], password.as_bytes())
.0,
0
);
s
}
#[test]
fn an_agent_works_on_a_repository() {
let s = logged_in();
let c = Client::new(&s);
let tools = c.rt.block_on(c.peer.list_all_tools()).unwrap();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
for tool in ["read_file", "commit", "search", "create_repo", "share"] {
assert!(names.contains(&tool), "{names:?}");
}
let created = c.json(
"create_repo",
json!({"name": "agentic", "description": "made by a tool"}),
);
let repo = created["repo"].as_str().unwrap().to_string();
assert_eq!(repo, format!("{}/agentic", s.account.handle));
let (text, err) = c.call("create_repo", json!({"name": "agentic"}));
assert!(err && text.contains("already exists"), "{text}");
let first = c.json(
"commit",
json!({"repo": repo, "branch": "main", "message": "start", "changes": [
{"path": "README.md", "content": "# agentic\n\nhello world\n"},
{"path": "src/main.rs", "content": "fn main() {}\n"},
{"path": "bin/run", "content": "#!/bin/sh\n", "executable": true},
{"path": "logo.bin", "base64": "AAEC/w=="},
]}),
);
assert_eq!(first["changed"], true);
let second = c.json(
"commit",
json!({"repo": format!("knotless://{repo}"), "branch": "main", "message": "tidy", "changes": [
{"path": "bin/run", "delete": true},
{"path": "README.md", "content": "# agentic\n\nhello there\n"},
], "base": first["sha"]}),
);
let (text, err) = c.call(
"commit",
json!({"repo": repo, "branch": "main", "message": "x", "changes": [{"path": "a", "content": "x", "delete": true}]}),
);
assert!(err && text.contains("exactly one"), "{text}");
let (text, err) = c.call(
"commit",
json!({"repo": repo, "branch": "main", "message": "x", "changes": [{"path": "a", "base64": "!!"}]}),
);
assert!(err, "{text}");
let refs = c.json("refs", json!({"repo": repo}));
assert_eq!(refs["refs"][0]["sha"], second["sha"]);
assert_eq!(refs["private"], false);
let tree = c.json("list_tree", json!({"repo": repo}));
let names: Vec<&str> = tree
.as_array()
.unwrap()
.iter()
.map(|e| e["name"].as_str().unwrap())
.collect();
assert_eq!(names, ["README.md", "logo.bin", "src"]);
let sub = c.json(
"list_tree",
json!({"repo": repo, "path": "src", "rev": first["sha"]}),
);
assert_eq!(sub[0]["name"], "main.rs");
assert_eq!(
c.ok("read_file", json!({"repo": repo, "path": "README.md"})),
"# agentic\n\nhello there\n"
);
assert_eq!(
c.json("read_file", json!({"repo": repo, "path": "logo.bin"}))["base64"],
"AAEC/w=="
);
let hits = c.json("search", json!({"repo": repo, "pattern": "HELLO"}));
assert_eq!(hits[0]["path"], "README.md");
assert_eq!(hits[0]["line"], 3);
let log = c.json("log", json!({"repo": repo, "limit": 5}));
assert_eq!(log.as_array().unwrap().len(), 2);
assert_eq!(log[0]["message"], "tidy");
let touched = c.json("log", json!({"repo": repo, "path": "src"}));
assert_eq!(touched.as_array().unwrap().len(), 1);
let diff = c.ok(
"diff",
json!({"repo": repo, "base": first["sha"], "head": "main"}),
);
assert!(
diff.contains("-hello world") && diff.contains("+hello there"),
"{diff}"
);
// A new branch starts from the default branch, or from `base`, and
// its commits are the committer's.
let feature = c.json("commit", json!({"repo": repo, "branch": "feature", "message": "f", "changes": [{"path": "f", "content": "f\n"}]}));
assert_eq!(feature["parent"], second["sha"]);
let fix = c.json("commit", json!({"repo": repo, "branch": "fix", "message": "x", "changes": [{"path": "x", "content": "x\n"}], "base": first["sha"]}));
assert_eq!(fix["parent"], first["sha"]);
let log = c.json("log", json!({"repo": repo, "rev": "feature", "limit": 1}));
assert_eq!(log[0]["author"]["name"], s.account.handle.as_str());
// Branches are made and deleted without a commit.
let develop = c.json("create_branch", json!({"repo": repo, "branch": "develop"}));
assert_eq!(develop["sha"], second["sha"]);
let old = c.json(
"create_branch",
json!({"repo": repo, "branch": "old", "from": first["sha"]}),
);
assert_eq!(old["sha"], first["sha"]);
c.json("delete_branch", json!({"repo": repo, "branch": "old"}));
c.json(
"create_branch",
json!({"repo": repo, "branch": "gone", "from": "main"}),
);
c.json("delete_branch", json!({"repo": repo, "branch": "gone"}));
// A deleted branch can be made again.
c.json("create_branch", json!({"repo": repo, "branch": "gone"}));
c.json("delete_branch", json!({"repo": repo, "branch": "gone"}));
let refs_now = c.json("refs", json!({"repo": repo}))["refs"].to_string();
assert!(
refs_now.contains("develop") && !refs_now.contains("refs/heads/old"),
"{refs_now}"
);
for (tool, args, why) in [
(
"create_branch",
json!({"repo": repo, "branch": "develop"}),
"already has",
),
(
"create_branch",
json!({"repo": repo, "branch": "y", "from": "nope"}),
"no commit or branch",
),
(
"delete_branch",
json!({"repo": repo, "branch": "old"}),
"has no branch",
),
(
"delete_branch",
json!({"repo": repo, "branch": "main"}),
"default branch",
),
] {
let (text, err) = c.call(tool, args);
assert!(err && text.contains(why), "{tool}: {text}");
}
let repos = c.json("list_repos", json!({"owner": s.account.handle}));
assert_eq!(repos[0]["name"], "agentic");
assert_eq!(repos[0]["description"], "made by a tool");
let members = c.json("members", json!({"repo": repo}));
assert_eq!(members[0]["access"], "owner");
let protect = |extra: Value| {
let mut args = json!({"repo": repo, "branch": "main"});
args.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
c.json("protect_branch", args)
};
let rule = |history| json!([{"branch": "main", "history": history}]);
assert_eq!(protect(json!({})), rule("linear"));
assert_eq!(protect(json!({"merges": true})), rule("merges"));
assert_eq!(protect(json!({"remove": true})), json!([]));
// Renamed, it answers to both names.
let refs = c.json("refs", json!({"repo": repo}));
let renamed = c.json("rename_repo", json!({"repo": repo, "name": "agentic2"}));
let moved = format!("knotless://{}/agentic2", s.account.handle);
assert_eq!(renamed["url"], moved);
assert_eq!(c.json("refs", json!({"repo": repo}))["refs"], refs["refs"]);
assert_eq!(c.json("refs", json!({"repo": moved}))["refs"], refs["refs"]);
// Errors come back as tool errors, for the agent to read.
for (tool, args) in [
("read_file", json!({"repo": repo, "path": "nope"})),
(
"read_file",
json!({"repo": repo, "path": "README.md", "rev": "--all"}),
),
("list_tree", json!({"repo": "not a repo"})),
(
"list_tree",
json!({"repo": format!("{}/missing", s.account.handle)}),
),
(
"refs",
json!({"repo": format!("{}/missing", s.account.handle)}),
),
("search", json!({"repo": repo, "pattern": ""})),
("log", json!({"repo": repo, "rev": "-x"})),
("diff", json!({"repo": repo, "base": "-x", "head": "main"})),
("list_repos", json!({"owner": "no-such-handle.test"})),
(
"members",
json!({"repo": format!("{}/missing", s.account.handle)}),
),
("share", json!({"repo": repo, "with": s.account.handle})),
(
"protect_branch",
json!({"repo": repo, "branch": "main", "remove": true}),
),
(
"protect_branch",
json!({"repo": format!("{}/missing", s.account.handle), "branch": "main"}),
),
("rename_repo", json!({"repo": "not a repo", "name": "x"})),
// With no appview, there is no gateway to assume.
("delegate", json!({})),
] {
let (text, err) = c.call(tool, args.clone());
assert!(err, "{tool} {args}: {text}");
}
}
#[test]
fn sharing_from_an_agent() {
if !net().spaces {
eprintln!("skipped: the PDS is not on the spaces alpha");
return;
}
let s = logged_in();
let c = Client::new(&s);
let created = c.json("create_repo", json!({"name": "secret", "private": true}));
let repo = created["repo"].as_str().unwrap().to_string();
c.json("commit", json!({"repo": repo, "branch": "main", "message": "m", "changes": [{"path": "a", "content": "a"}]}));
assert_eq!(c.json("refs", json!({"repo": repo}))["private"], true);
// Its owner sees it listed, once, under the name it has now.
let mine = |c: &Client| -> Vec<Value> {
c.json("list_repos", json!({"owner": s.account.handle}))
.as_array()
.unwrap()
.iter()
.filter(|r| r["private"] == true)
.cloned()
.collect()
};
assert_eq!(mine(&c)[0]["name"], "secret");
c.json("rename_repo", json!({"repo": repo, "name": "hidden"}));
let listed = mine(&c);
assert_eq!(listed.len(), 1, "{listed:?}");
assert_eq!(listed[0]["name"], "hidden");
c.json(
"rename_repo",
json!({"repo": format!("{}/hidden", s.account.handle), "name": "secret"}),
);
let reader = logged_in();
let other = reader.account.clone();
let members = c.json("share", json!({"repo": repo, "with": other.handle}));
assert_eq!(
members[1],
json!({"did": other.did, "handle": other.handle, "access": "read"})
);
// A reader may not list the others.
let (text, err) = Client::new(&reader).call("members", json!({"repo": repo}));
assert!(err && text.contains("owner can list"), "{text}");
let members = c.json(
"share",
json!({"repo": repo, "with": other.handle, "revoke": true}),
);
assert_eq!(members.as_array().unwrap().len(), 1);
}
#[test]
fn nobody_logged_in() {
let mut s = Sandbox::new();
s.set("KNOTLESS_PASSWORD", "");
let c = Client::new(&s);
let (text, err) = c.call("create_repo", json!({"name": "x"}));
assert!(err && text.contains("not logged in"), "{text}");
// An identifier and password in the environment act as that account.
let mut s = Sandbox::new();
let handle = s.account.handle.clone();
s.set("KNOTLESS_IDENTIFIER", &handle);
let c = Client::new(&s);
let created = c.json("create_repo", json!({"name": "from-env"}));
assert_eq!(created["repo"], format!("{handle}/from-env"));
}
#[test]
fn the_binary_speaks_mcp_on_stdio() {
use std::io::{BufRead, Write};
let s = Sandbox::new();
let mut child = s
.command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
writeln!(
stdin,
"{}",
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "t", "version": "0"}}})
)
.unwrap();
let mut line = String::new();
std::io::BufReader::new(child.stdout.take().unwrap())
.read_line(&mut line)
.unwrap();
let res: Value = serde_json::from_str(&line).unwrap();
assert_eq!(res["result"]["serverInfo"]["name"], "knotless");
drop(stdin);
assert!(child.wait().unwrap().success());
// A client that leaves before it says hello is an error.
let out = s
.command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path())
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(!out.status.success());
}
/// An appview following the PDS, for listing public repositories' pulls
/// and issues.
/// It takes reports too, and its admin password is `hunter2`.
/// Its OAuth callback is on an https proxy in front of it, which the
/// returned `TlsProxy` keeps up.
fn appview(rt: &tokio::runtime::Runtime) -> (String, Sandbox, TlsProxy) {
let mut s = Sandbox::new();
s.set("KNOTLESS_ADMIN_PASSWORD", "hunter2");
let listener = rt
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
.unwrap();
let base = format!("http://localhost:{}", listener.local_addr().unwrap().port());
let index = knotless_serve::index::Index::open(&s.path("index.db")).unwrap();
let proxy = tls_proxy(listener.local_addr().unwrap().port());
let mut gateway = knotless_serve::gateway::Gateway::new(&base).unwrap();
gateway.oauth_url = proxy.url.clone();
let app = knotless_serve::App::build(
ctx(&s),
std::time::Duration::ZERO,
Some(gateway),
Some(index),
false,
);
let router = knotless_serve::router(app.clone());
rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
rt.spawn(knotless_serve::firehose::watch(
app,
net().pds.replace("http://", "ws://"),
));
(base, s, proxy)
}
#[test]
fn agents_fork_review_and_merge() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt);
let mut owner = logged_in();
owner.set("KNOTLESS_APPVIEW", &base);
// Delegating is approved in the browser.
let browser = browser(&owner.account, "");
owner.set("KNOTLESS_BROWSER", &browser);
// The appview is a gateway too, at a did:web served over http.
owner.set("KNOTLESS_DID_WEB_SCHEME", "http");
let mut contrib = logged_in();
contrib.set("KNOTLESS_APPVIEW", &base);
contrib.set("KNOTLESS_DID_WEB_SCHEME", "http");
let (o, c) = (Client::new(&owner), Client::new(&contrib));
let repo = c_repo(&o.json("create_repo", json!({"name": "reviewed"})));
o.json("commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
let fork = c_repo(&c.json("fork", json!({"repo": repo})));
assert_eq!(fork, format!("{}/reviewed", contrib.account.handle));
let change = c.json("commit", json!({"repo": fork, "branch": "main", "message": "b", "changes": [{"path": "b", "content": "b\n"}]}));
// A collaborator needs the owner's writes delegated to a gateway: the
// appview, unless another is named.
let (text, err) = o.call(
"share",
json!({"repo": repo, "with": contrib.account.handle, "write": true}),
);
assert!(err && text.contains("`delegate` tool"), "{text}");
let (text, err) = c.call("create_branch", json!({"repo": repo, "branch": "theirs"}));
assert!(err && text.contains("only its owner can"), "{text}");
let gateway = o.json("delegate", json!({}))["delegated"].clone();
assert!(
gateway
.as_str()
.unwrap()
.starts_with("did:web:localhost%3A"),
"{gateway}"
);
o.json("delegate", json!({"gateway": gateway}));
o.json(
"share",
json!({"repo": repo, "with": contrib.account.handle, "write": true}),
);
c.json("create_branch", json!({"repo": repo, "branch": "theirs"}));
c.json("delete_branch", json!({"repo": repo, "branch": "theirs"}));
o.json(
"share",
json!({"repo": repo, "with": contrib.account.handle, "revoke": true}),
);
let pull = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "main", "title": "Add b", "body": "small"}));
let pull = pull["uri"].as_str().unwrap().to_string();
let issue = c.json("open_issue", json!({"repo": repo, "title": "Missing c"}))["uri"]
.as_str()
.unwrap()
.to_string();
let listed = |state: &str| -> Vec<Value> {
o.json(
"list_items",
json!({"repo": repo, "kind": "pull", "state": state}),
)
.as_array()
.unwrap()
.clone()
};
wait_until(|| listed("open").len() == 1);
// Authors come with their handles.
assert_eq!(listed("open")[0]["handle"], contrib.account.handle.as_str());
wait_until(|| {
o.json("list_items", json!({"repo": repo}))
.as_array()
.unwrap()
.len()
== 2
});
o.json(
"comment",
json!({"repo": repo, "uri": pull, "body": "good", "verdict": "approve"}),
);
let (text, err) = o.call(
"comment",
json!({"repo": repo, "uri": pull, "body": "?", "verdict": "maybe"}),
);
assert!(err, "{text}");
wait_until(|| {
o.json("show_item", json!({"repo": repo, "uri": pull}))["comments"]
.as_array()
.unwrap()
.len()
== 1
});
let shown = o.json("show_item", json!({"repo": repo, "uri": pull}));
assert_eq!(shown["item"]["handle"], contrib.account.handle.as_str());
assert_eq!(shown["item"]["approvals"], 1);
// The pull's author points it at the head branch it already names.
assert_eq!(
c.json(
"retarget_pull",
json!({"repo": repo, "uri": pull, "head": "main"})
)["retargeted"],
pull
);
let (text, err) = o.call(
"retarget_pull",
json!({"repo": repo, "uri": pull, "base": "nope"}),
);
assert!(err, "{text}");
// Before merging: what it brings in, that it merges, and the review.
let shown = o.json("show_item", json!({"repo": repo, "uri": pull}));
assert_eq!(shown["compare"]["mergeable"], "fast-forward");
assert_eq!(shown["compare"]["head"], change["sha"]);
assert_eq!(shown["compare"]["commits"].as_array().unwrap().len(), 1);
assert_eq!(shown["comments"][0]["body"], "good");
assert_eq!(shown["comments"][0]["verdict"], "approve");
assert_eq!(
shown["comments"][0]["handle"],
owner.account.handle.as_str()
);
let diff = o.ok("pull_diff", json!({"repo": repo, "uri": pull}));
assert!(diff.contains("+b"), "{diff}");
assert_eq!(
o.json("merge", json!({"repo": repo, "uri": pull}))["sha"],
change["sha"]
);
wait_until(|| listed("merged").len() == 1);
let (text, err) = o.call("merge", json!({"repo": repo, "uri": pull}));
assert!(err, "{text}");
let shown = o.json("show_item", json!({"repo": repo, "uri": pull}));
assert_eq!(shown["compare"]["mergeable"], "merged");
// A pull that conflicts says where.
o.json("commit", json!({"repo": repo, "branch": "main", "message": "mine", "changes": [{"path": "a", "content": "owner\n"}]}));
c.json("commit", json!({"repo": fork, "branch": "clash", "message": "theirs", "changes": [{"path": "a", "content": "contrib\n"}]}));
let clash = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "clash", "title": "Clash"}));
let clash = clash["uri"].as_str().unwrap().to_string();
let compared = &o.json("show_item", json!({"repo": repo, "uri": clash}))["compare"];
assert_eq!(compared["mergeable"], "conflict");
assert_eq!(compared["conflicts"], json!(["a"]));
let (text, err) = o.call("merge", json!({"repo": repo, "uri": clash}));
assert!(err && text.contains("conflicts in a"), "{text}");
// One that touches other files merges cleanly though main moved on.
c.json("commit", json!({"repo": fork, "branch": "aside", "message": "c", "changes": [{"path": "c", "content": "c\n"}]}));
let aside = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "aside", "title": "Aside"}));
let aside = aside["uri"].as_str().unwrap().to_string();
let compared = &o.json("show_item", json!({"repo": repo, "uri": aside}))["compare"];
assert_eq!(compared["mergeable"], "clean");
// Renaming a branch takes the pulls on it along.
o.json("create_branch", json!({"repo": repo, "branch": "develop"}));
let onto = c.json("open_pull", json!({"repo": repo, "base": "develop", "head_repo": fork, "head_branch": "clash", "title": "Onto develop"}));
let onto = onto["uri"].as_str().unwrap().to_string();
wait_until(|| listed("open").iter().any(|i| i["uri"] == onto.as_str()));
let renamed = o.json(
"rename_branch",
json!({"repo": repo, "from": "develop", "to": "next"}),
);
assert_eq!(renamed["retargeted"], json!([onto]));
wait_until(|| {
o.json("show_item", json!({"repo": repo, "uri": onto}))["item"]["base"] == "next"
});
let (text, err) = o.call(
"rename_branch",
json!({"repo": repo, "from": "main", "to": "trunk"}),
);
assert!(err && text.contains("default branch"), "{text}");
// A pull whose head is gone still shows, and says so.
c.json("delete_branch", json!({"repo": fork, "branch": "clash"}));
let compared = &o.json("show_item", json!({"repo": repo, "uri": clash}))["compare"];
assert!(
compared["error"]
.as_str()
.unwrap()
.contains("has no branch clash"),
"{compared}"
);
// An issue has nothing to compare.
let shown = o.json("show_item", json!({"repo": repo, "uri": issue}));
assert!(shown.get("compare").is_none(), "{shown}");
assert_eq!(
o.json(
"set_state",
json!({"repo": repo, "uri": issue, "state": "closed"})
)["state"],
"closed"
);
let (text, err) = o.call(
"set_state",
json!({"repo": repo, "uri": issue, "state": "merged"}),
);
assert!(err, "{text}");
// Offered to the contributor, withdrawn, offered again, and taken up.
let them = contrib.account.handle.clone();
o.json("transfer_repo", json!({"repo": repo, "to": them}));
o.json("transfer_repo", json!({"repo": repo}));
let offered = o.json(
"transfer_repo",
json!({"repo": repo, "to": format!("{them}/kept")}),
);
assert_eq!(offered["offeredTo"], format!("knotless://{them}/kept"));
let moved = c.json("accept_transfer", json!({"repo": repo}));
assert_eq!(
moved["url"],
format!("knotless://{}/kept", contrib.account.did)
);
for (tool, args) in [
("transfer_repo", json!({"repo": "nobody.test/x", "to": "x"})),
("accept_transfer", json!({"repo": "nobody.test/x"})),
("fork", json!({"repo": "nobody.test/x"})),
(
"open_pull",
json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "none", "title": "t"}),
),
("open_issue", json!({"repo": "nobody.test/x", "title": "t"})),
("list_items", json!({"repo": "nobody.test/x"})),
("pull_diff", json!({"repo": repo, "uri": issue})),
(
"show_item",
json!({"repo": repo, "uri": "at://did:plc:nobody/dev.knotless.issue/x"}),
),
] {
let (text, err) = o.call(tool, args.clone());
assert!(err, "{tool} {args}: {text}");
}
}
#[test]
fn agents_moderate() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt);
let mut owner = logged_in();
owner.set("KNOTLESS_APPVIEW", &base);
// Delegating is approved in the browser.
let browser = browser(&owner.account, "");
owner.set("KNOTLESS_BROWSER", &browser);
let mut operator = logged_in();
operator.set("KNOTLESS_APPVIEW", &base);
operator.set("KNOTLESS_ADMIN_PASSWORD", "hunter2");
let (o, c) = (Client::new(&owner), Client::new(&operator));
let repo = c_repo(&o.json("create_repo", json!({"name": "moderated"})));
o.json("commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
let issue = c.json("open_issue", json!({"repo": repo, "title": "Spam"}))["uri"]
.as_str()
.unwrap()
.to_string();
let items = || {
o.json("list_items", json!({"repo": repo}))
.as_array()
.unwrap()
.len()
};
wait_until(|| items() == 1);
let hidden = o.json("hide_item", json!({"repo": repo, "uri": issue}));
assert_eq!(hidden["hidden"], true);
wait_until(|| items() == 0);
o.json(
"hide_item",
json!({"repo": repo, "uri": issue, "hidden": false}),
);
wait_until(|| items() == 1);
let them = operator.account.handle.clone();
assert_eq!(
o.json("block_account", json!({"account": them}))["blocked"],
true
);
wait_until(|| items() == 0);
o.json("block_account", json!({"account": them, "blocked": false}));
wait_until(|| items() == 1);
o.json("block_account", json!({"account": them, "repo": repo}));
wait_until(|| items() == 0);
o.json(
"block_account",
json!({"account": them, "repo": repo, "blocked": false}),
);
wait_until(|| items() == 1);
let report = c.json(
"report",
json!({"subject": repo, "reason": "spam", "text": "all of it"}),
);
assert_eq!(report["id"], 1);
assert_eq!(
report["subject"]["uri"],
format!("at://{}/dev.knotless.repo/moderated", owner.account.did)
);
c.json(
"report",
json!({"subject": owner.account.handle, "reason": "rude"}),
);
let reports = c.json("list_reports", json!({}));
assert_eq!(reports["reports"].as_array().unwrap().len(), 2);
let down = c.json("takedown", json!({"subject": issue}));
assert_eq!(down["takedown"]["applied"], true);
wait_until(|| items() == 0);
let status = c.json("moderation_status", json!({"subject": issue}));
assert_eq!(status["takedown"]["applied"], true);
c.json("takedown", json!({"subject": issue, "applied": false}));
wait_until(|| items() == 1);
for (who, tool, args) in [
(&c, "hide_item", json!({"repo": repo, "uri": issue})),
(&o, "block_account", json!({"account": "nobody.test"})),
(&c, "report", json!({"subject": repo, "reason": "boring"})),
(&o, "takedown", json!({"subject": issue})),
(&o, "moderation_status", json!({"subject": issue})),
(&o, "list_reports", json!({})),
] {
let (text, err) = who.call(tool, args.clone());
assert!(err, "{tool} {args}: {text}");
}
}
fn c_repo(v: &Value) -> String {
v["repo"].as_str().unwrap().to_string()
}