johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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 {
Client::knowing(s, &Default::default())
}
/// A client of a server told what `known` says of versions.
fn knowing(s: &Sandbox, known: &knotless::update::Known) -> Client {
Client::of(knotless_mcp::Server::new(ctx(s)).knowing(known))
}
/// A client of `server`.
fn of(server: knotless_mcp::Server) -> Client {
let rt = tokio::runtime::Runtime::new().unwrap();
let peer = rt.block_on(async {
let (server_io, client_io) = tokio::io::duplex(1 << 20);
tokio::spawn(async move {
let running = server.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))
}
/// Each of the tool's texts, when it answers more than one.
fn texts(&self, tool: &str, args: Value) -> Vec<String> {
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();
assert_ne!(res.is_error, Some(true), "{tool}: {:?}", res.content);
let texts = res
.content
.iter()
.map(|c| c.as_text().unwrap().text.clone());
texts.collect()
}
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()
}
/// The resource at `uri`, its one content as JSON (`mimeType`, and
/// `text` or `blob`), or the error it is answered with.
fn read(&self, uri: &str) -> Result<Value, rmcp::model::ErrorData> {
let params = rmcp::model::ReadResourceRequestParams::new(uri);
match self.rt.block_on(self.peer.read_resource(params)) {
Ok(read) => {
assert_eq!(read.contents.len(), 1, "{read:?}");
Ok(serde_json::to_value(&read.contents[0]).unwrap())
}
Err(rmcp::ServiceError::McpError(err)) => Err(err),
Err(err) => panic!("{uri}: {err}"),
}
}
}
/// 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 [
"repo_read",
"repo_commit",
"repo_search",
"repo_create",
"repo_share",
] {
assert!(names.contains(&tool), "{names:?}");
}
let created = c.json(
"repo_create",
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("repo_create", json!({"name": "agentic"}));
assert!(err && text.contains("already exists"), "{text}");
let first = c.json(
"repo_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(
"repo_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(
"repo_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(
"repo_commit",
json!({"repo": repo, "branch": "main", "message": "x", "changes": [{"path": "a", "base64": "!!"}]}),
);
assert!(err, "{text}");
let refs = c.json("repo_refs", json!({"repo": repo}));
assert_eq!(refs["refs"][0]["sha"], second["sha"]);
assert_eq!(refs["private"], false);
// Two commits, two packs, both small enough to ride inline.
let storage = c.json("repo_storage", json!({"repo": repo}));
assert_eq!(storage["packs"], 2);
assert!(storage["bytes"].as_u64().unwrap() > 0, "{storage}");
assert_eq!(storage["blobBytes"], 0);
let tree = c.json("repo_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(
"repo_tree",
json!({"repo": repo, "path": "src", "rev": first["sha"]}),
);
assert_eq!(sub[0]["name"], "main.rs");
assert_eq!(
c.ok("repo_read", json!({"repo": repo, "path": "README.md"})),
"# agentic\n\nhello there\n"
);
assert_eq!(
c.json("repo_read", json!({"repo": repo, "path": "logo.bin"}))["base64"],
"AAEC/w=="
);
let hits = c.json("repo_search", json!({"repo": repo, "pattern": "HELLO"}));
assert_eq!(hits[0]["path"], "README.md");
assert_eq!(hits[0]["line"], 3);
// Who last changed each run of lines: the line tidied since, after two
// from the start.
let blamed = c.json("repo_blame", json!({"repo": repo, "path": "README.md"}));
assert_eq!(
(&blamed[0]["sha"], &blamed[0]["count"]),
(&first["sha"], &json!(2))
);
assert_eq!(
(&blamed[1]["start"], &blamed[1]["summary"]),
(&json!(3), &json!("tidy"))
);
assert_eq!(blamed[1]["previous"]["sha"], first["sha"]);
let log = c.json("repo_log", json!({"repo": repo, "limit": 5}));
assert_eq!(log.as_array().unwrap().len(), 2);
assert_eq!(log[0]["message"], "tidy");
let touched = c.json("repo_log", json!({"repo": repo, "path": "src"}));
assert_eq!(touched.as_array().unwrap().len(), 1);
let diff = c.ok(
"repo_diff",
json!({"repo": repo, "base": first["sha"], "head": "main"}),
);
assert!(
diff.contains("-hello world") && diff.contains("+hello there"),
"{diff}"
);
// Nothing changed says so, rather than nothing at all.
let same = c.ok(
"repo_diff",
json!({"repo": repo, "base": "main", "head": "main"}),
);
assert_eq!(same, "No differences: main changes nothing since main.");
// A new branch starts from the default branch, or from `base`, and
// its commits are the committer's.
let feature = c.json("repo_commit", json!({"repo": repo, "branch": "feature", "message": "f", "changes": [{"path": "f", "content": "f\n"}]}));
assert_eq!(feature["parent"], second["sha"]);
let fix = c.json("repo_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(
"repo_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("branch_create", json!({"repo": repo, "name": "develop"}));
assert_eq!(develop["sha"], second["sha"]);
let old = c.json(
"branch_create",
json!({"repo": repo, "name": "old", "from": first["sha"]}),
);
assert_eq!(old["sha"], first["sha"]);
c.json("branch_delete", json!({"repo": repo, "name": "old"}));
c.json(
"branch_create",
json!({"repo": repo, "name": "gone", "from": "main"}),
);
c.json("branch_delete", json!({"repo": repo, "name": "gone"}));
// A deleted branch can be made again.
c.json("branch_create", json!({"repo": repo, "name": "gone"}));
c.json("branch_delete", json!({"repo": repo, "name": "gone"}));
let refs_now = c.json("repo_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 [
(
"branch_create",
json!({"repo": repo, "name": "develop"}),
"already has",
),
(
"branch_create",
json!({"repo": repo, "name": "y", "from": "nope"}),
"no commit or branch",
),
(
"branch_delete",
json!({"repo": repo, "name": "old"}),
"has no branch",
),
(
"branch_delete",
json!({"repo": repo, "name": "main"}),
"default branch",
),
] {
let (text, err) = c.call(tool, args);
assert!(err && text.contains(why), "{tool}: {text}");
}
// Tags are made and deleted as branches are, and never twice.
let v1 = c.json("tag_create", json!({"repo": repo, "name": "v1"}));
assert_eq!(v1["sha"], second["sha"]);
let v0 = c.json(
"tag_create",
json!({"repo": repo, "name": "v0", "from": first["sha"]}),
);
assert_eq!(v0["sha"], first["sha"]);
let refs_now = c.json("repo_refs", json!({"repo": repo}))["refs"].to_string();
assert!(refs_now.contains("refs/tags/v1"), "{refs_now}");
c.json("tag_delete", json!({"repo": repo, "name": "v0"}));
for (tool, args, why) in [
(
"tag_create",
json!({"repo": repo, "name": "v1"}),
"already has a tag",
),
(
"tag_delete",
json!({"repo": repo, "name": "v0"}),
"has no tag",
),
] {
let (text, err) = c.call(tool, args);
assert!(err && text.contains(why), "{tool}: {text}");
}
let repos = c.json("repo_list", json!({"handle": s.account.handle}));
assert_eq!(repos[0]["name"], "agentic");
assert_eq!(repos[0]["description"], "made by a tool");
let members = c.json("repo_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("branch_protect", 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!({"undo": true})), json!([]));
// What its runners report: here the owner runs its own. Naming none,
// it has nothing to show or to ask.
for (tool, args) in [
("run_list", json!({"repo": repo})),
("run_rerun", json!({"repo": repo, "ref": "refs/heads/main"})),
] {
let (text, err) = c.call(tool, args);
assert!(err && text.contains("names no runners"), "{tool}: {text}");
}
assert_eq!(c.json("runner_list", json!({"repo": repo})), json!([]));
let handle = s.account.handle.clone();
let named = c.json("runner_add", json!({"repo": repo, "handle": handle}));
assert_eq!(named, json!([{"did": s.account.did, "handle": handle}]));
let named = c.json("runner_remove", json!({"repo": repo, "handle": handle}));
assert_eq!(named, json!([]));
// Agents likewise.
assert_eq!(c.json("agent_list", json!({"repo": repo})), json!([]));
let named = c.json("agent_add", json!({"repo": repo, "handle": handle}));
assert_eq!(named, json!([{"did": s.account.did, "handle": handle}]));
let named = c.json("agent_remove", json!({"repo": repo, "handle": handle}));
assert_eq!(named, json!([]));
c.json("runner_add", json!({"repo": repo, "handle": handle}));
// Its minutes: none counted, then what the runner filed for its owner.
let counted = c.json("runner_minutes", json!({"repo": repo}));
let none = json!([{"runner": s.account.did, "handle": handle, "counted": null}]);
assert_eq!(counted, none);
let minutes = json!({"$type": "dev.knotless.minutes", "minutes": 12, "allowance": 2000, "updatedAt": "2026-10-25T00:00:00Z"});
s.put_record("dev.knotless.minutes", &s.account.did, minutes);
let counted = c.json("runner_minutes", json!({"repo": repo}));
assert_eq!(counted[0]["counted"]["minutes"], 12, "{counted}");
assert_eq!(counted[0]["counted"]["allowance"], 2000, "{counted}");
let key = s.record("dev.knotless.repo", "agentic")["key"]
.as_str()
.unwrap()
.to_string();
let commit = "a".repeat(40);
let rkey =
knotless::keys::check_rkey(&s.account.did, &key, "refs/heads/main", &commit, None, "ci");
let picture = s.upload_blob(b"GIF89a", "image/gif");
s.put_record("dev.knotless.check", &rkey, json!({
"$type": "dev.knotless.check", "repo": {"uri": format!("at://{}/dev.knotless.repo/agentic", s.account.did), "key": key},
"ref": "refs/heads/main", "commit": "a".repeat(40), "workflow": "ci", "status": "success",
"outputs": [
{"$type": "dev.knotless.check#image", "image": picture, "alt": "home 2 after"},
{"$type": "dev.knotless.check#trace", "name": "trace.zip"},
],
"startedAt": "2026-10-21T00:00:00.000Z",
}));
let checks = c.json(
"run_list",
json!({"repo": repo, "ref": "refs/heads/main", "log": true}),
);
assert_eq!(checks[0]["status"], "success");
// It has no log to give.
assert!(checks[0].get("logText").is_none(), "{checks}");
assert_eq!(checks[0]["runner"], s.account.did.as_str());
// With its pictures, each named, as images to look at; an output of a
// kind it does not know left out.
let params = CallToolRequestParams::new("run_list".to_string()).with_arguments(
json!({"repo": repo, "images": true})
.as_object()
.unwrap()
.clone(),
);
let res = c.rt.block_on(c.peer.call_tool(params)).unwrap();
assert_eq!(res.content.len(), 3);
assert_eq!(res.content[1].as_text().unwrap().text, "ci: home 2 after");
let image = res.content[2].as_image().unwrap();
assert_eq!(
(image.data.as_str(), image.mime_type.as_str()),
("R0lGODlh", "image/gif")
);
let none = "b".repeat(40);
let (text, err) = c.call("run_list", json!({"repo": repo, "commit": none, "wait": 1}));
assert!(err, "{text}");
assert!(
text.contains(&format!("no checks of commit {none} after 1s")),
"{text}"
);
let (text, err) = c.call("run_list", json!({"repo": repo, "commit": "main"}));
assert!(err && text.contains("a commit is its hex ID"), "{text}");
let asked = c.json("run_rerun", json!({"repo": repo, "ref": "refs/heads/main"}));
let asked = asked["asked"].as_str().unwrap();
assert!(asked.contains("/dev.knotless.rerun/"), "{asked}");
// Or one workflow at a ref, with variables.
let one = json!({"repo": repo, "ref": "refs/heads/main", "workflow": "scenarios", "env": {"TRACKS": "team"}});
let asked = c.json("run_rerun", one);
let rkey = asked["asked"].as_str().unwrap().rsplit('/').next().unwrap();
let rerun = s.record("dev.knotless.rerun", rkey);
assert_eq!(rerun["workflow"], "scenarios");
assert_eq!(rerun["env"], json!([{"name": "TRACKS", "value": "team"}]));
// Or to cancel what a ref or a pull has going, one workflow's or all.
let off = json!({"repo": repo, "ref": "main", "workflow": "deploy"});
let asked = c.json("run_cancel", off);
let rkey = asked["asked"].as_str().unwrap().rsplit('/').next().unwrap();
let cancel = s.record("dev.knotless.cancel", rkey);
assert_eq!(
(&cancel["ref"], &cancel["workflow"]),
(&json!("refs/heads/main"), &json!("deploy"))
);
let (text, err) = c.call("run_cancel", json!({"repo": repo}));
assert!(err && text.contains("name one ref or one pull"), "{text}");
// The workflows at a ref, and what runs each there: it has none.
let listed = c.json("workflow_list", json!({"repo": repo}));
assert_eq!(listed, json!({"ref": "refs/heads/main", "workflows": []}));
// Renamed, it answers to both names.
let refs = c.json("repo_refs", json!({"repo": repo}));
let renamed = c.json("repo_rename", json!({"repo": repo, "name": "agentic2"}));
let moved = format!("knotless://{}/agentic2", s.account.handle);
assert_eq!(renamed["url"], moved);
assert_eq!(
c.json("repo_refs", json!({"repo": repo}))["refs"],
refs["refs"]
);
assert_eq!(
c.json("repo_refs", json!({"repo": moved}))["refs"],
refs["refs"]
);
// Its description dropped and its default branch named, as `knotless
// describe` does, and its packs folded.
let links = json!([{"url": "https://agentic.example", "title": "Site"}]);
let topics = json!([{"name": "agents"}]);
let described = json!({"repo": moved, "description": "", "links": links, "topics": ["agents"], "default_branch": "main"});
let described = c.json("repo_edit", described);
assert_eq!(
described,
json!({"description": null, "links": links, "topics": topics, "defaultBranch": "main"})
);
assert!(
s.record("dev.knotless.repo", "agentic2")
.get("description")
.is_none()
);
let compacted = c.json("repo_compact", json!({ "repo": moved }));
assert!(compacted["before"].as_u64().unwrap() >= 2, "{compacted}");
assert!(compacted["removed"].is_u64() && compacted["claims"].is_u64());
// Errors come back as tool errors, for the agent to read.
for (tool, args) in [
("repo_read", json!({"repo": repo, "path": "nope"})),
(
"repo_read",
json!({"repo": repo, "path": "README.md", "rev": "--all"}),
),
("repo_tree", json!({"repo": "not a repo"})),
(
"repo_tree",
json!({"repo": format!("{}/missing", s.account.handle)}),
),
(
"repo_refs",
json!({"repo": format!("{}/missing", s.account.handle)}),
),
(
"repo_storage",
json!({"repo": format!("{}/missing", s.account.handle)}),
),
("repo_search", json!({"repo": repo, "pattern": ""})),
("repo_log", json!({"repo": repo, "rev": "-x"})),
(
"repo_blame",
json!({"repo": repo, "path": "README.md", "rev": "-x"}),
),
(
"repo_diff",
json!({"repo": repo, "base": "-x", "head": "main"}),
),
("repo_list", json!({"handle": "no-such-handle.test"})),
(
"repo_members",
json!({"repo": format!("{}/missing", s.account.handle)}),
),
(
"repo_share",
json!({"repo": repo, "handle": s.account.handle}),
),
(
"branch_protect",
json!({"repo": repo, "branch": "main", "undo": true}),
),
(
"branch_protect",
json!({"repo": format!("{}/missing", s.account.handle), "branch": "main"}),
),
("repo_rename", json!({"repo": "not a repo", "name": "x"})),
("repo_edit", json!({ "repo": moved })),
("repo_compact", json!({"repo": "not a repo"})),
("repo_delete", json!({"repo": moved, "confirm": "agentic"})),
// With no appview, there is no gateway to assume.
("account_delegate", json!({})),
] {
let (text, err) = c.call(tool, args.clone());
assert!(err, "{tool} {args}: {text}");
}
// Deleted, once confirmed by its name.
let deleted = c.json("repo_delete", json!({"repo": moved, "confirm": "agentic2"}));
assert_eq!(deleted["deleted"], moved);
assert!(s.record("dev.knotless.repo", "agentic2").is_null());
}
/// An agent deletes its account's knotless data: told what there is
/// first, then, confirmed by its handle, all of it. With no appview, there
/// is none to forget it.
/// What one call cost: the bytes the agent sent and was sent back, which
/// it pays for as tokens, and how long it took.
#[derive(Default, Clone, Copy)]
struct Cost {
sent: usize,
got: usize,
took: std::time::Duration,
}
impl std::ops::Add for Cost {
type Output = Cost;
fn add(self, o: Cost) -> Cost {
Cost {
sent: self.sent + o.sent,
got: self.got + o.got,
took: self.took + o.took,
}
}
}
impl Client {
/// The tool's text, and what the call cost.
fn costed(&self, tool: &str, args: Value) -> (String, Cost) {
let start = std::time::Instant::now();
let text = self.ok(tool, args.clone());
let cost = Cost {
sent: args.to_string().len(),
got: text.len(),
took: start.elapsed(),
};
(text, cost)
}
}
/// The bench for changing one line of a long file, kept as a test so it
/// cannot regress: the way an agent had to (read it all, send it all
/// back) against the way it can now (search with context, commit an
/// edit). Both land the same file; the second costs a small part of the
/// first, whatever the file's length. Its table is in docs/reference/mcp.md.
#[test]
fn an_agent_changes_a_line_of_a_long_file() {
let s = logged_in();
let c = Client::new(&s);
let repo = format!("{}/long", s.account.handle);
// About 130 KB, as the longest files in this repository are.
let long: String = (0..1600)
.map(|n| format!(" pub async fn item_{n:04}(&self) -> Result<u64> {{ self.get(\"item-{n:04}\", {n}).await }}\n"))
.collect();
let base = c.json(
"repo_commit",
json!({"repo": repo, "branch": "main", "message": "long", "changes": [
{"path": "src/long.rs", "content": long},
{"path": "README.md", "content": "item_0800 is here too\n"},
]}),
);
let old = "self.get(\"item-0800\", 800)";
let new = "self.get(\"item-0800\", 801)";
// Before: the whole file read, and the whole file sent back.
let at = |branch: &str| json!({"repo": repo, "path": "src/long.rs", "rev": branch});
let (read, r1) = c.costed("repo_read", at("main"));
let content = read.replace(old, new);
let (_, r2) = c.costed(
"repo_commit",
json!({"repo": repo, "branch": "whole", "message": "m",
"base": base["sha"], "changes": [{"path": "src/long.rs", "content": content}]}),
);
let before = r1 + r2;
// Now: the line found with what is around it, and only it sent back.
let (found, n1) = c.costed(
"repo_search",
json!({"repo": repo, "pattern": "item_0800(", "path": "src", "context": 2}),
);
let runs: Value = serde_json::from_str(&found).unwrap();
assert_eq!(
(runs.as_array().unwrap().len(), &runs[0]["matches"]),
(1, &json!([801]))
);
assert!(runs[0]["text"].as_str().unwrap().contains(old), "{runs}");
let (_, n2) = c.costed("repo_commit", json!({"repo": repo, "branch": "edited", "message": "m",
"base": base["sha"], "changes": [{"path": "src/long.rs", "edits": [{"old": old, "new": new}]}]}));
let now = n1 + n2;
assert_eq!(
c.ok("repo_read", at("whole")),
c.ok("repo_read", at("edited"))
);
let tokens = |c: Cost| (c.sent + c.got) / 4;
println!(
"changing a line of a {} byte file, two calls each:",
long.len()
);
for (way, cost) in [("before", before), ("now", now)] {
let (sent, got, ms) = (cost.sent, cost.got, cost.took.as_millis());
println!(
" {way:6} sent {sent:>7} B got {got:>7} B ~{:>6} tokens {ms:>5} ms",
tokens(cost)
);
}
let times = tokens(before) / tokens(now).max(1);
println!(" {times}x fewer tokens");
assert!(
times >= 50,
"edits cost {}, a whole file {}",
tokens(now),
tokens(before)
);
// A part of a file, and who last changed it.
let part = c.json(
"repo_read",
json!({"repo": repo, "path": "src/long.rs", "start": 801, "end": 802}),
);
assert_eq!(
(&part["start"], &part["end"], &part["lines"]),
(&json!(801), &json!(802), &json!(1600))
);
assert!(
part["text"]
.as_str()
.unwrap()
.starts_with(" pub async fn item_0800("),
"{part}"
);
let head = c.json(
"repo_read",
json!({"repo": repo, "path": "README.md", "end": 1}),
);
assert_eq!(head["text"], "item_0800 is here too\n");
let blamed = c.json(
"repo_blame",
json!({"repo": repo, "path": "src/long.rs", "rev": "edited", "start": 801}),
);
assert_eq!(
(&blamed[0]["start"], &blamed[0]["count"]),
(&json!(801), &json!(1))
);
let everywhere = c.json(
"repo_search",
json!({"repo": repo, "pattern": "item_0800", "limit": 5}),
);
assert_eq!(everywhere.as_array().unwrap().len(), 2);
// What an agent gets wrong comes back for it to put right.
let edit = |old: &str| {
json!({"repo": repo, "branch": "edited", "message": "m",
"changes": [{"path": "src/long.rs", "edits": [{"old": old, "new": "x"}]}]})
};
for (args, says) in [
(
edit("item-0800\", 800)"),
"edit 1 of src/long.rs: old is not in the file",
),
(edit("pub async fn"), "old is in the file 1600 times"),
(
json!({"repo": repo, "branch": "edited", "message": "m",
"changes": [{"path": "a", "content": "x", "edits": []}]}),
"exactly one",
),
(
json!({"repo": repo, "path": "src/long.rs", "start": 0}),
"no lines 0 to 1600",
),
] {
let tool = if args.get("changes").is_some() {
"repo_commit"
} else {
"repo_read"
};
let (text, err) = c.call(tool, args);
assert!(err && text.contains(says), "{text}");
}
}
#[test]
fn an_agent_deletes_its_knotless_data() {
let s = logged_in();
let c = Client::new(&s);
c.json("repo_create", json!({"name": "gone", "runners": []}));
let held = c.json("account_delete_data", json!({}));
let repos = json!([{"name": "gone", "private": false, "collaborators": 0}]);
assert_eq!(held["repos"], repos, "{held}");
let (why, err) = c.call("account_delete_data", json!({"confirm": "nope"}));
assert!(err && why.contains(&s.account.handle), "{why}");
assert!(!s.record("dev.knotless.repo", "gone").is_null());
let confirm = json!({"confirm": s.account.handle});
let done = c.json("account_delete_data", confirm);
assert!(done["deleted"].as_u64().unwrap() >= 1, "{done}");
assert!(s.record("dev.knotless.repo", "gone").is_null());
assert!(s.records("dev.knotless.repo").is_empty());
}
#[test]
fn an_agent_sets_its_profile() {
let s = logged_in();
let c = Client::new(&s);
assert_eq!(c.json("account_profile", json!({})), json!({}));
let set = c.json(
"account_profile",
json!({"bio": "Writes tools.", "pronouns": "they/them", "avatar": "iVBORw0KGgo=",
"links": [{"url": "https://agent.example", "title": "Home"}]}),
);
assert_eq!(set["description"], "Writes tools.");
assert_eq!(set["avatar"]["mimeType"], "image/png");
let record = s.record("dev.knotless.profile", "self");
assert_eq!(record["pronouns"], "they/them");
assert_eq!(record["links"][0]["title"], "Home");
let (text, err) = c.call("account_profile", json!({"avatar": "not base64!"}));
assert!(err, "{text}");
let left = c.json("account_profile", json!({"name": "Agent"}));
assert_eq!(
(&left["displayName"], &left["pronouns"]),
(&json!("Agent"), &json!("they/them"))
);
}
#[test]
fn a_template_asks_for_a_body() {
let s = logged_in();
let c = Client::new(&s);
let repo = c_repo(&c.json("repo_create", json!({"name": "templated"})));
// A new repository's default branch can be named, as with `knotless create`.
let trunk = json!({"name": "trunked", "default_branch": "trunk"});
let trunked = c_repo(&c.json("repo_create", trunk));
let refs = c.json("repo_refs", json!({ "repo": trunked }));
assert_eq!(refs["defaultBranch"], "trunk");
let pull_md =
"<!-- Title: what it does, under 70 characters. -->\n\n## What changes\n\n## Why\n";
c.json(
"repo_commit",
json!({"repo": repo, "branch": "main", "message": "templates", "changes": [
{"path": ".knotless/pull.md", "content": pull_md},
{"path": ".knotless/issue.md", "content": "## What happened\n\n## What you expected\n"},
]}),
);
c.json("branch_create", json!({"repo": repo, "name": "change"}));
let pull_template = |args: Value| c.json("pull_template", args)["template"].clone();
assert_eq!(pull_template(json!({"repo": repo})), pull_md);
assert_eq!(
pull_template(json!({"repo": repo, "base": "change"})),
pull_md
);
assert_eq!(
pull_template(json!({"repo": repo, "base": "gone"})),
Value::Null
);
let issue_template = c.json("issue_template", json!({"repo": repo}))["template"].clone();
assert!(
issue_template
.as_str()
.unwrap()
.contains("What you expected")
);
let open = |body: Option<&str>| {
let mut args = json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "change", "title": "A change"});
if let Some(b) = body {
args["body"] = json!(b);
}
c.call("pull_create", args)
};
// No body, a blank one, or only the template's notes: the template
// comes back to write the title and body from.
let notes = "<!-- Title: what it does, under 70 characters. -->\n";
for body in [None, Some(" \n"), Some(notes)] {
let (text, err) = open(body);
assert!(
err && text.contains("title and body") && text.contains(pull_md),
"{text}"
);
}
// The notes copied in are left out, as forge's tests show.
let (text, err) = open(Some(&format!("{notes}\n## What changes\n\nA line.\n")));
assert!(!err, "{text}");
// A body with none of the template's sections is refused, naming them;
// one with any of them, at any level, goes in.
let (text, err) = open(Some("Just prose, as before."));
assert!(
err && text.contains("none of its sections (what changes, why)"),
"{text}"
);
let (text, err) = c.call("issue_create", json!({"repo": repo, "title": "Broken"}));
assert!(
err && text.contains("has no body") && text.contains("## What you expected"),
"{text}"
);
c.json(
"issue_create",
json!({"repo": repo, "title": "Broken", "body": "### what happened\n\nIt <!-- broke -->."}),
);
}
#[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("repo_create", json!({"name": "secret", "private": true}));
let repo = created["repo"].as_str().unwrap().to_string();
c.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "m", "changes": [{"path": "a", "content": "a"}]}));
assert_eq!(c.json("repo_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("repo_list", json!({"handle": s.account.handle}))
.as_array()
.unwrap()
.iter()
.filter(|r| r["private"] == true)
.cloned()
.collect()
};
assert_eq!(mine(&c)[0]["name"], "secret");
c.json("repo_rename", json!({"repo": repo, "name": "hidden"}));
let listed = mine(&c);
assert_eq!(listed.len(), 1, "{listed:?}");
assert_eq!(listed[0]["name"], "hidden");
c.json(
"repo_rename",
json!({"repo": format!("{}/hidden", s.account.handle), "name": "secret"}),
);
let reader = logged_in();
let other = reader.account.clone();
let members = c.json("repo_share", json!({"repo": repo, "handle": 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("repo_members", json!({"repo": repo}));
assert!(err && text.contains("owner can list"), "{text}");
// Read-only, logged in as the reader, it reads the repository all the
// same: an agent that must change nothing reads what its account may.
let read_only = Client::of(knotless_mcp::Server::new(ctx(&reader)).read_only());
assert_eq!(
read_only.ok("repo_read", json!({"repo": repo, "path": "a"})),
"a"
);
let members = c.json(
"repo_share",
json!({"repo": repo, "handle": other.handle, "undo": 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("repo_create", 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("repo_create", json!({"name": "from-env"}));
assert_eq!(created["repo"], format!("{handle}/from-env"));
}
#[test]
fn an_old_version_is_refused_and_a_new_one_told_of() {
let mut s = Sandbox::new();
let behind = knotless::update::Known {
checked: 0,
latest: Some("999.0.0".into()),
minimum: Some("999.0.0".into()),
reason: Some("Compaction loses objects.".into()),
};
let mine = json!({"handle": s.account.handle});
let (said, err) = Client::knowing(&s, &behind).call("repo_list", mine.clone());
assert!(err && said.contains("Compaction loses objects."), "{said}");
// Its resources too, however well formed the URI.
let file = format!("knotless://{}/x/file/main/a", s.account.handle);
let refused = Client::knowing(&s, &behind).read(&file).unwrap_err();
assert!(
refused.message.contains("Compaction loses objects."),
"{refused:?}"
);
// Told to go on anyway, it does, and says once that 999.0.0 is out.
s.set("KNOTLESS_IGNORE_MINIMUM", "1");
let c = Client::knowing(&s, &behind);
let first = c.texts("repo_list", mine.clone());
assert!(first.last().unwrap().starts_with("knotless 999.0.0 is out"));
let again = c.texts("repo_list", mine);
assert_eq!(again.len(), first.len() - 1);
}
/// A read-only server lists only the tools that read, each one the full
/// server has, and runs none of the others.
#[test]
fn a_read_only_server_only_reads() {
let s = Sandbox::new();
let names = |c: &Client| -> Vec<String> {
let tools = c.rt.block_on(c.peer.list_all_tools()).unwrap();
tools.into_iter().map(|t| t.name.to_string()).collect()
};
let all = names(&Client::new(&s));
let reader = Client::of(knotless_mcp::Server::new(ctx(&s)).read_only());
let mut read = names(&reader);
read.sort();
let mut reads: Vec<String> = knotless_mcp::READS.iter().map(|t| t.to_string()).collect();
reads.sort();
assert_eq!(read, reads);
assert!(reads.iter().all(|t| all.contains(t)), "{reads:?}");
assert!(all.contains(&"repo_commit".to_string()));
let mine = json!({"repo": format!("{}/nothing", s.account.handle), "branch": "main", "message": "m", "changes": []});
let params =
CallToolRequestParams::new("repo_commit").with_arguments(mine.as_object().unwrap().clone());
let refused = reader.rt.block_on(reader.peer.call_tool(params));
assert!(refused.is_err(), "{refused:?}");
reader.ok("repo_list", json!({"handle": s.account.handle}));
}
#[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());
// Read-only, it says hello the same; it takes no other flag.
let mut child = s
.command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path())
.arg("--read-only")
.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();
assert!(line.contains("\"knotless\""), "{line}");
drop(stdin);
assert!(child.wait().unwrap().success());
let out = s
.command(env!("CARGO_BIN_EXE_knotless-mcp"), s.dir.path())
.arg("--bogus")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
// 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, from the accounts `operators` names.
/// Its OAuth callback is on an https proxy in front of it, which the
/// returned `TlsProxy` keeps up.
fn appview(rt: &tokio::runtime::Runtime, operators: &str) -> (String, Sandbox, TlsProxy) {
let mut s = Sandbox::new();
s.set("KNOTLESS_ADMIN_DIDS", operators);
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 mut app = knotless_serve::App::build(
ctx(&s),
std::time::Duration::ZERO,
Some(gateway),
Some(index),
);
// It keeps its logs, for its operators to read.
let logs = knotless_serve::logs::Logs::open(&s.path("logs.db"), knotless_serve::logs::KEEP);
std::sync::Arc::get_mut(&mut app).unwrap().logs = Some(std::sync::Arc::new(logs.unwrap()));
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("repo_create", json!({"name": "reviewed"})));
// Its owner gives its runner a secret, sealed to the key it publishes,
// and lists it, never its value, then takes it back.
let dir = tempfile::tempdir().unwrap();
let keys = knotless::sealed::RunnerKeys::open_or_make(&dir.path().join("k.json")).unwrap();
let nsid = knotless::sealed::KEY_NSID;
let key = json!({"$type": nsid, "key": keys.public().did_key(), "createdAt": "2026-10-29T00:00:00.000Z"});
contrib.put_record(nsid, knotless::sealed::KEY_RKEY, key);
o.json(
"runner_add",
json!({"repo": repo, "handle": contrib.account.handle}),
);
let set = json!({"repo": repo, "name": "TOKEN", "value": "v4lue", "refs": ["refs/heads/main"]});
let listed = o.json("secret_set", set);
assert_eq!(listed[0]["name"], "TOKEN", "{listed}");
assert_eq!(o.json("secret_list", json!({"repo": repo})), listed);
assert_eq!(listed[0]["refs"], json!([{"pattern": "refs/heads/main"}]));
assert!(listed[0].get("sealed").is_none(), "{listed}");
let gone = o.json("secret_remove", json!({"repo": repo, "name": "TOKEN"}));
assert_eq!(gone, json!([]));
o.json(
"runner_remove",
json!({"repo": repo, "handle": contrib.account.handle}),
);
o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
let fork = c_repo(&c.json("repo_fork", json!({"repo": repo})));
assert_eq!(fork, format!("{}/reviewed", contrib.account.handle));
let change = c.json("repo_commit", json!({"repo": fork, "branch": "main", "message": "b", "changes": [{"path": "b", "content": "b\n"}]}));
// A collaborator pushes through a gateway, the owner's writes
// delegated to it: sharing for writing first delegates them to the
// appview, and says so after who may write.
let (text, err) = c.call("branch_create", json!({"repo": repo, "name": "theirs"}));
assert!(err && text.contains("only its owner can"), "{text}");
let shared = o.texts(
"repo_share",
json!({"repo": repo, "handle": contrib.account.handle, "write": true}),
);
let members: Value = serde_json::from_str(&shared[0]).unwrap();
assert_eq!(members[1]["access"], "write", "{members}");
assert_eq!(shared.len(), 2, "{shared:?}");
let first = "Delegated your writes to the gateway did:web:localhost%3A";
assert!(shared[1].starts_with(first), "{shared:?}");
// Delegated to already, it shares at once; another gateway may be
// named.
let shared = o.texts(
"repo_share",
json!({"repo": repo, "handle": contrib.account.handle, "write": true}),
);
assert_eq!(shared.len(), 1, "{shared:?}");
let gateway = o.json("account_delegate", json!({}))["delegated"].clone();
assert!(
gateway
.as_str()
.unwrap()
.starts_with("did:web:localhost%3A"),
"{gateway}"
);
o.json("account_delegate", json!({"gateway": gateway}));
c.json("branch_create", json!({"repo": repo, "name": "theirs"}));
c.json("branch_delete", json!({"repo": repo, "name": "theirs"}));
o.json(
"repo_share",
json!({"repo": repo, "handle": contrib.account.handle, "undo": true}),
);
let pull = c.json("pull_create", 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("issue_create", json!({"repo": repo, "title": "Missing c"}))["uri"]
.as_str()
.unwrap()
.to_string();
let listed = |state: &str| -> Vec<Value> {
o.json(
"pull_list",
json!({"repo": repo, "query": format!("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(|| {
let pulls = o.json("pull_list", json!({"repo": repo}));
let issues = o.json("issue_list", json!({"repo": repo}));
pulls.as_array().unwrap().len() + issues.as_array().unwrap().len() == 2
});
// A query filters them, as GitHub's search does.
let missing = o.json(
"issue_list",
json!({"repo": repo, "query": "state:open missing"}),
);
assert_eq!(missing.as_array().unwrap().len(), 1, "{missing}");
assert_eq!(missing[0]["uri"], issue.as_str());
o.json(
"comment_add",
json!({"repo": repo, "uri": pull, "body": "good", "approve": true}),
);
let (text, err) = o.call(
"comment_add",
json!({"repo": repo, "uri": pull, "body": "?", "approve": true, "changes": true}),
);
assert!(err, "{text}");
// Its author comments on it, and reviews nothing of their own.
let mine = json!({"repo": repo, "uri": pull, "body": "mine", "approve": true});
let (text, err) = c.call("comment_add", mine);
assert!(err && text.contains("your own pull"), "{text}");
wait_until(|| {
o.json("pull_show", json!({"repo": repo, "uri": pull}))["comments"]
.as_array()
.unwrap()
.len()
== 1
});
let shown = o.json("pull_show", json!({"repo": repo, "uri": pull}));
assert_eq!(shown["item"]["handle"], contrib.account.handle.as_str());
assert_eq!(shown["item"]["reviews"].as_array().unwrap().len(), 1);
// The pull's author points it at the head branch it already names.
assert_eq!(
c.json(
"pull_retarget",
json!({"repo": repo, "uri": pull, "head": "main"})
)["retargeted"],
pull
);
let (text, err) = o.call(
"pull_retarget",
json!({"repo": repo, "uri": pull, "base": "nope"}),
);
assert!(err, "{text}");
// Its author changes what a pull or a comment says, and takes a
// comment back.
let edited = c.json(
"pull_edit",
json!({"repo": repo, "uri": pull, "title": "Add b, please", "body": format!("smaller, for {issue}")}),
);
assert_eq!(edited["edited"], pull);
// Made a draft and ready again, but not both at once.
let (text, err) = c.call(
"pull_edit",
json!({"repo": repo, "uri": pull, "draft": true, "ready": true}),
);
assert!(err && text.contains("not both"), "{text}");
c.json(
"pull_edit",
json!({"repo": repo, "uri": pull, "draft": true}),
);
c.json(
"pull_edit",
json!({"repo": repo, "uri": pull, "ready": true}),
);
let retitled = json!({"repo": repo, "uri": issue, "title": "Missing c, please"});
assert_eq!(c.json("issue_edit", retitled)["edited"], issue);
let said = c.json(
"comment_add",
json!({"repo": repo, "uri": pull, "body": "one more"}),
)["uri"]
.as_str()
.unwrap()
.to_string();
// A picture shown after what it says, attached while it shows it.
let png = {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(b"\x89PNG\r\n\x1a\nshot")
};
let shot = json!([{"base64": png, "alt": "the page"}]);
c.json(
"comment_edit",
json!({"repo": repo, "uri": said, "body": "see", "images": shot}),
);
let rkey = said.rsplit('/').next().unwrap();
let record = contrib.record("dev.knotless.comment", rkey);
assert!(
record["body"]
.as_str()
.unwrap()
.starts_with("see\n\n,
"{record}"
);
assert_eq!(
record["attachments"].as_array().unwrap().len(),
1,
"{record}"
);
let (text, err) = c.call(
"pull_edit",
json!({"repo": repo, "uri": pull, "images": shot}),
);
assert!(err && text.contains("go with a body"), "{text}");
let broken = json!([{"base64": "not base64!", "alt": "x"}]);
let (text, err) = c.call(
"comment_add",
json!({"repo": repo, "uri": pull, "body": "x", "images": broken}),
);
assert!(err, "{text}");
c.json(
"comment_edit",
json!({"repo": repo, "uri": said, "body": "one more thing"}),
);
wait_until(|| {
let shown = o.json("pull_show", json!({"repo": repo, "uri": pull}));
let comments = shown["comments"].as_array().unwrap().clone();
shown["item"]["title"] == "Add b, please"
&& shown["item"]["body"] == format!("smaller, for {issue}")
&& comments
.iter()
.any(|x| x["body"] == "one more thing" && x["updatedAt"].is_string())
});
// The issue it names says so.
let shown = o.json("issue_show", json!({"repo": repo, "uri": issue}));
assert_eq!(shown["mentionedBy"], json!([{"pull": pull}]), "{shown}");
assert_eq!(
c.json("comment_delete", json!({"repo": repo, "uri": said}))["deleted"],
said
);
wait_until(|| {
o.json("pull_show", json!({"repo": repo, "uri": pull}))["comments"]
.as_array()
.unwrap()
.len()
== 1
});
// Before merging: what it brings in, that it merges, and the review.
let shown = o.json("pull_show", 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");
let review = &shown["item"]["reviews"][0];
assert_eq!(
(&review["by"], &review["verdict"]),
(&json!(owner.account.did), &json!("approve"))
);
assert_eq!(review["commit"], change["sha"]);
assert_eq!(
shown["comments"][0]["handle"],
owner.account.handle.as_str()
);
// A comment on its lines, answered in its thread, which its author
// resolves.
let lines = json!([{"path": "b", "line": 1, "side": "new", "body": "Why b?"}]);
let wrote = o.json(
"comment_add",
json!({"repo": repo, "uri": pull, "body": "", "lines": lines}),
);
let on = wrote["uri"].as_str().unwrap().to_string();
assert_eq!(wrote["written"], json!([on]));
// A review sends at most a hundred.
let many: Vec<Value> = (1..=101)
.map(|n| json!({"path": "b", "line": n, "body": "And this"}))
.collect();
let (text, err) = o.call(
"comment_add",
json!({"repo": repo, "uri": pull, "body": "", "lines": many}),
);
assert!(err && text.contains("at most 100"), "{text}");
c.json(
"comment_add",
json!({"repo": repo, "uri": on, "body": "Because"}),
);
c.json("comment_resolve", json!({"repo": repo, "uri": on}));
let thread = || o.json("pull_show", json!({"repo": repo, "uri": pull}))["comments"].clone();
let said = |c: &Value| c["reply"] == on.as_str();
wait_until(|| thread().as_array().unwrap().iter().any(said));
let comments = thread();
let started = comments
.as_array()
.unwrap()
.iter()
.find(|c| c["uri"] == on.as_str());
let started = started.unwrap();
assert_eq!(
(&started["line"]["path"], &started["resolved"]),
(&json!("b"), &json!(true))
);
// Its answers stand as the thread does.
let answer = comments.as_array().unwrap().iter().find(|c| said(c));
assert_eq!(answer.unwrap()["resolved"], true);
let diff = o.ok("pull_diff", json!({"repo": repo, "uri": pull}));
// Each line numbered as the comment on it above took it: b's line 1,
// on the new side, and none on the old.
assert!(diff.contains("@@ -0,0 +1 @@\n 1 | +b\n"), "{diff}");
// Asked for by path, the file's part of it is the same.
let part = o.ok(
"pull_diff",
json!({"repo": repo, "uri": pull, "paths": ["b"]}),
);
assert!(part.contains("@@ -0,0 +1 @@\n 1 | +b\n"), "{part}");
// Merged, it says how, and why its branch stays: it is in the fork.
let merged = o.json("pull_merge", json!({"repo": repo, "uri": pull}));
assert_ne!(merged["sha"], change["sha"]);
assert_eq!(merged["method"], "merge");
let kept = merged["kept"].as_str().unwrap();
assert!(kept.starts_with("main is in another repository"), "{kept}");
wait_until(|| listed("merged").len() == 1);
let (text, err) = o.call("pull_merge", json!({"repo": repo, "uri": pull}));
assert!(err, "{text}");
// Merged, it compares as its merge brought it in, though its branch
// moves on.
c.json("repo_commit", json!({"repo": fork, "branch": "main", "message": "later", "changes": [{"path": "z", "content": "z\n"}]}));
let shown = o.json("pull_show", json!({"repo": repo, "uri": pull}));
let compared = &shown["compare"];
assert_eq!(compared["mergeable"], "merged");
// The merge commit, and the commit it brought in.
assert_eq!(compared["head"], merged["sha"]);
assert_eq!(compared["commits"].as_array().unwrap().len(), 2);
// Merged, it reverts in a pull of its own, on a branch of its own.
let reverted = o.json(
"pull_revert",
json!({"repo": repo, "uri": pull, "reason": "not yet"}),
);
let rkey = pull.rsplit('/').next().unwrap();
assert_eq!(reverted["branch"], format!("revert/{rkey}"));
assert!(
reverted["pull"]
.as_str()
.unwrap()
.contains("dev.knotless.pull")
);
let (text, err) = o.call("pull_revert", json!({"repo": repo, "uri": pull}));
assert!(err && text.contains("has a branch"), "{text}");
// The revert names what it undoes, and once it merges, that names it.
let undo = reverted["pull"].as_str().unwrap();
wait_until(|| listed("open").iter().any(|i| i["uri"] == undo));
let shown = o.json("pull_show", json!({"repo": repo, "uri": undo}));
assert_eq!(shown["item"]["reverts"], pull.as_str());
assert!(shown["item"].get("revertedBy").is_none(), "{shown}");
o.json("pull_merge", json!({"repo": repo, "uri": undo}));
wait_until(|| {
let shown = o.json("pull_show", json!({"repo": repo, "uri": pull}));
shown["item"]["revertedBy"] == undo
});
// A pull that conflicts says where.
o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "mine", "changes": [{"path": "a", "content": "owner\n"}]}));
c.json("repo_commit", json!({"repo": fork, "branch": "clash", "message": "theirs", "changes": [{"path": "a", "content": "contrib\n"}]}));
let clash = c.json("pull_create", 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("pull_show", json!({"repo": repo, "uri": clash}))["compare"];
assert_eq!(compared["mergeable"], "conflict");
assert_eq!(compared["conflicts"], json!(["a"]));
let (text, err) = o.call("pull_merge", json!({"repo": repo, "uri": clash}));
assert!(err && text.contains("conflicts in a"), "{text}");
// Its branch is in the fork: the fork's owner brings it up to date.
let (text, err) = o.call("pull_update_branch", json!({"repo": repo, "uri": clash}));
assert!(err && text.contains("in another repository"), "{text}");
// Merged, a pull's branch is not brought up to date.
let (text, err) = o.call("pull_update_branch", json!({"repo": repo, "uri": pull}));
assert!(err && text.contains("only an open pull"), "{text}");
// One here is, the lines both sides added at one place kept, main's first.
o.json("branch_create", json!({"repo": repo, "name": "notes"}));
o.json("repo_commit", json!({"repo": repo, "branch": "notes", "message": "n", "changes": [{"path": "a", "content": "owner\nnotes\n"}]}));
o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "m", "changes": [{"path": "a", "content": "owner\nmain\n"}]}));
let notes = o.json("pull_create", json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "notes", "title": "Notes"}));
let notes = notes["uri"].as_str().unwrap().to_string();
let updated = o.json("pull_update_branch", json!({"repo": repo, "uri": notes}));
assert_eq!(updated["branch"], "notes");
assert_eq!(updated["added"], json!(["a"]));
let read = o.ok(
"repo_read",
json!({"repo": repo, "path": "a", "rev": "notes"}),
);
assert_eq!(read, "owner\nmain\nnotes\n");
// One that touches other files merges cleanly though main moved on.
c.json("repo_commit", json!({"repo": fork, "branch": "aside", "message": "c", "changes": [{"path": "c", "content": "c\n"}]}));
let aside = c.json("pull_create", 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("pull_show", json!({"repo": repo, "uri": aside}))["compare"];
assert_eq!(compared["mergeable"], "clean");
// One whose head the base holds already brings in nothing.
o.json("branch_create", json!({"repo": repo, "name": "level"}));
let level = o.json("pull_create", json!({"repo": repo, "base": "level", "head_repo": repo, "head_branch": "main", "title": "Level"}));
let level = level["uri"].as_str().unwrap().to_string();
let compared = &o.json("pull_show", json!({"repo": repo, "uri": level}))["compare"];
assert_eq!(compared["mergeable"], "merged");
assert_eq!(compared["commits"], json!([]));
// Once main moves on, it merges, and main, the default branch, stays.
o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "l", "changes": [{"path": "l", "content": "l\n"}]}));
let merged = o.json("pull_merge", json!({"repo": repo, "uri": level}));
let kept = merged["kept"].as_str().unwrap();
assert!(kept.contains("the branch it merges into"), "{kept}");
// Renaming a branch takes the pulls on it along.
o.json("branch_create", json!({"repo": repo, "name": "develop"}));
let onto = c.json("pull_create", 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(
"branch_rename",
json!({"repo": repo, "name": "develop", "new_name": "next"}),
);
assert_eq!(renamed["retargeted"], json!([onto]));
wait_until(|| {
o.json("pull_show", json!({"repo": repo, "uri": onto}))["item"]["base"] == "next"
});
let (text, err) = o.call(
"branch_rename",
json!({"repo": repo, "name": "main", "new_name": "trunk"}),
);
assert!(err && text.contains("default branch"), "{text}");
// A pull whose head is gone still shows, and says so.
c.json("branch_delete", json!({"repo": fork, "name": "clash"}));
let compared = &o.json("pull_show", 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("issue_show", json!({"repo": repo, "uri": issue}));
assert!(shown.get("compare").is_none(), "{shown}");
// The owner defines labels and puts them on; the issue's author may not.
let bug = json!({"repo": repo, "name": "kind/bug", "color": "#d73a4a"});
o.json("label_define", bug);
let docs = json!({"repo": repo, "name": "kind/docs", "exclusive": true});
let defined = o.json("label_define", docs);
assert_eq!(defined[1]["exclusive"], true, "{defined}");
assert_eq!(o.json("label_list", json!({"repo": repo})), defined);
let both = json!({"repo": repo, "uri": issue, "labels": ["kind/bug", "kind/docs"]});
let (text, err) = c.call("issue_label", both.clone());
assert!(err, "{text}");
assert_eq!(
o.json("issue_label", both)["labels"],
json!(["kind/bug", "kind/docs"])
);
let off = json!({"repo": repo, "uri": issue, "labels": ["kind/bug"], "undo": true});
assert_eq!(o.json("issue_label", off)["labels"], json!(["kind/docs"]));
// A pull takes labels the same way.
let on_pull = json!({"repo": repo, "uri": clash, "labels": ["kind/bug"]});
assert_eq!(o.json("pull_label", on_pull)["labels"], json!(["kind/bug"]));
// The owner assigns it, and asks for a review of a pull besides its
// author; the shown item names them.
let them = contrib.account.handle.clone();
let assigning = json!({"repo": repo, "uri": issue, "handles": [them]});
let (text, err) = c.call("issue_assign", assigning.clone());
assert!(err, "{text}");
let assigned = o.json("issue_assign", assigning);
assert_eq!(assigned["assignees"], json!([contrib.account.did]));
// The shown issue names them once the appview hears of the assignment.
let shown = || o.json("issue_show", json!({"repo": repo, "uri": issue}));
wait_until(|| shown()["item"]["assignees"][0]["handle"] == them.as_str());
let me = owner.account.handle.clone();
let asking = json!({"repo": repo, "uri": clash, "handles": [me]});
assert_eq!(
o.json("pull_ask_review", asking.clone())["reviewers"],
json!([owner.account.did])
);
let mut stop = asking.clone();
stop["undo"] = json!(true);
assert_eq!(o.json("pull_ask_review", stop)["reviewers"], json!([]));
o.json("pull_ask_review", asking);
let off = json!({"repo": repo, "uri": issue, "handles": [them], "undo": true});
assert_eq!(o.json("issue_assign", off)["assignees"], json!([]));
// A pull is assigned the same way.
let to_pull = json!({"repo": repo, "uri": clash, "handles": [them]});
assert_eq!(
o.json("pull_assign", to_pull)["assignees"],
json!([contrib.account.did])
);
// The owner asks for changes to it, then dismisses that review: the
// shown pull says so, and its author may not.
let review = json!({"repo": repo, "uri": clash, "body": "no", "changes": true});
o.json("comment_add", review);
let dismissing = json!({"repo": repo, "uri": clash, "reviewer": me, "reason": "moot"});
let (text, err) = c.call("pull_dismiss_review", dismissing.clone());
assert!(err, "{text}");
let dismissal = o.json("pull_dismiss_review", dismissing)["uri"].clone();
// The shown pull says so once the appview hears of the dismissal.
let shown = || o.json("pull_show", json!({"repo": repo, "uri": clash}));
wait_until(|| shown()["item"]["reviews"][0]["dismissed"] == dismissal);
let shown = shown();
let mine = &shown["item"]["reviews"][0];
assert_eq!(
(&mine["by"], &mine["dismissed"]),
(&json!(owner.account.did), &dismissal)
);
let said = shown["comments"].as_array().unwrap();
assert!(
said.iter().any(|c| c["dismisses"] == mine["uri"]),
"{shown}"
);
let gone = o.json("label_delete", json!({"repo": repo, "name": "kind/bug"}));
assert_eq!(gone.as_array().unwrap().len(), 1, "{gone}");
let bot = contrib.account.handle.clone();
let named = o.json(
"triager_add",
json!({"repo": repo, "handle": bot, "scopes": ["kind"]}),
);
assert_eq!(named[0]["scopes"][0]["name"], "kind", "{named}");
assert_eq!(o.json("triager_list", json!({"repo": repo})), named);
let unnamed = o.json("triager_remove", json!({"repo": repo, "handle": bot}));
assert_eq!(unnamed, json!([]));
let closing = json!({"repo": repo, "uri": issue});
assert_eq!(o.json("issue_close", closing.clone())["state"], "closed");
assert_eq!(o.json("issue_reopen", closing)["state"], "open");
let closing = json!({"repo": repo, "uri": clash});
assert_eq!(o.json("pull_close", closing.clone())["state"], "closed");
assert_eq!(o.json("pull_reopen", closing)["state"], "open");
let (text, err) = o.call("comment_resolve", json!({"repo": repo, "uri": issue}));
assert!(err, "{text}");
// Queued, a pull waits for a runner to land it, and leaves when asked.
let (text, err) = o.call(
"pull_merge",
json!({"repo": repo, "uri": aside, "queue": true}),
);
assert!(err && text.contains("names no runners"), "{text}");
o.json(
"runner_add",
json!({"repo": repo, "handle": owner.account.handle}),
);
// Nor is there a queue until a rule of the branch turns it on.
let (text, err) = o.call(
"pull_merge",
json!({"repo": repo, "uri": aside, "queue": true}),
);
assert!(
err && text.contains("does not use the merge queue"),
"{text}"
);
// Set to merge when its checks pass it says so, until cancelled.
let auto = json!({"repo": repo, "uri": aside, "auto": true});
let set = o.json("pull_merge", auto.clone());
assert_eq!(set["autoMerge"], "set", "{set}");
// Once the index has the record.
wait_until(|| {
let shown = o.json("pull_show", json!({"repo": repo, "uri": aside}));
let said = shown["autoMerge"].as_str().unwrap_or_default();
said.starts_with("merge when checks pass: set by")
});
let undo = json!({"repo": repo, "uri": aside, "auto": true, "undo": true});
assert_eq!(o.json("pull_merge", undo)["autoMerge"], "cancelled");
let on = json!({"repo": repo, "branch": "main", "merges": true, "queue": true});
o.json("branch_protect", on);
// Asked first, it would merge: it has no workflows to wait for. Its
// branch is in the fork, so nothing goes with it.
let checked = o.json(
"pull_merge",
json!({"repo": repo, "uri": aside, "check": true}),
);
assert_eq!(checked["state"], "clean", "{checked}");
assert_eq!(checked["checks"], "none", "{checked}");
assert_eq!(checked["method"], "merge", "{checked}");
assert_eq!(checked.get("deletes"), None, "{checked}");
let may = json!({"merge": true, "force": true, "queue": true, "autoMerge": false, "base": true, "head": false, "rerun": true});
assert_eq!(checked["may"], may);
let queued = o.json(
"pull_merge",
json!({"repo": repo, "uri": aside, "queue": true}),
);
assert_eq!(
queued["queued"],
json!({"base": "main", "position": 1, "of": 1})
);
wait_until(|| {
o.json("queue_list", json!({"repo": repo}))
.as_array()
.unwrap()
.len()
== 1
});
let line = o.json("queue_list", json!({"repo": repo}));
assert_eq!(line[0]["uri"], aside.as_str());
assert_eq!(line[0]["queue"]["state"], "queued");
let batches = o.json("run_list", json!({"repo": repo, "queue": "main"}));
assert_eq!(batches, json!([]));
let shown = o.json("pull_show", json!({"repo": repo, "uri": aside}));
assert_eq!(shown["standing"]["said"], "Queued, 1st of 1", "{shown}");
assert_eq!(shown["standing"]["place"]["position"], 1);
assert!(shown["standing"]["batch"].is_null());
assert_eq!(
o.json("queue_leave", json!({"repo": repo, "uri": aside}))["left"],
aside.as_str()
);
wait_until(|| o.json("queue_list", json!({"repo": repo})) == json!([]));
let (text, err) = o.call("queue_leave", json!({"repo": repo, "uri": aside}));
assert!(err && text.contains("not in the merge queue"), "{text}");
// Into a branch that takes pulls only through the queue, a merge
// queues it instead, saying why.
let main = json!({"repo": repo, "branch": "main", "queue": true});
let held = json!([{"branch": "main", "history": "linear", "queue": true}]);
assert_eq!(o.json("branch_protect", main), held);
assert_eq!(o.json("branch_rules", json!({"repo": repo})), held);
let checked = o.json(
"pull_merge",
json!({"repo": repo, "uri": aside, "check": true}),
);
assert_eq!(checked["queueOnly"], true, "{checked}");
let queued = o.json("pull_merge", json!({"repo": repo, "uri": aside}));
assert_eq!(
queued,
json!({"queued": {"base": "main", "position": 1, "of": 1}, "why": "main takes pulls only through the merge queue"})
);
wait_until(|| o.json("queue_list", json!({"repo": repo})) != json!([]));
o.json("queue_leave", json!({"repo": repo, "uri": aside}));
wait_until(|| o.json("queue_list", json!({"repo": repo})) == json!([]));
let unheld = json!({"repo": repo, "branch": "main", "undo": true});
assert_eq!(o.json("branch_protect", unheld.clone()), json!([]));
// A gate named by its handle is kept by its DID.
let gate = json!({"repo": repo, "branch": "main", "gates": [contrib.account.handle]});
let rules = o.json("branch_protect", gate);
let gates = &rules[0]["reviews"]["gates"];
assert_eq!(gates[0]["did"], contrib.account.did.as_str(), "{rules}");
assert_eq!(o.json("branch_protect", unheld.clone()), json!([]));
// Into a branch that asks for a review, an approval from someone who
// may not write counts for nothing, and each answer says why.
o.json("repo_commit", json!({"repo": repo, "branch": "mine", "message": "d", "changes": [{"path": "d", "content": "d\n"}]}));
let them = contrib.account.handle.clone();
let opened = json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "mine", "title": "Add d", "reviewers": [them]});
let mine = o.json("pull_create", opened)["uri"]
.as_str()
.unwrap()
.to_string();
let shown = || o.json("pull_show", json!({"repo": repo, "uri": mine}));
// Asked to review it, they are waited on until they do.
wait_until(|| shown()["item"]["reviewers"][0]["pending"] == true);
let body = format!("Fine, @{}", owner.account.handle);
let lines = json!([{"path": "d", "line": 1, "body": "Why d?"}]);
let approval =
json!({"repo": repo, "uri": mine, "body": body, "approve": true, "lines": lines});
let asks = json!({"repo": repo, "branch": "main", "reviews": 1});
o.json("branch_protect", asks);
let approved = c.json("comment_add", approval);
let note = approved["note"].as_str().unwrap();
assert!(note.contains("does not count"), "{approved}");
let review = approved["uri"].as_str().unwrap().to_string();
// Asked at once, the check reads the review just written, not
// waiting for the appview to hear of it.
let at_once = c.json(
"pull_merge",
json!({"repo": repo, "uri": mine, "check": true}),
);
let counted = at_once["reviewsUncounted"].as_array();
assert_eq!(counted.map(Vec::len), Some(1), "{at_once}");
wait_until(|| shown()["comments"].as_array().unwrap().len() == 2);
let s = shown();
assert_eq!(s["item"]["draft"], false, "{s}");
let asked = &s["item"]["reviewers"][0];
assert_eq!(
(&asked["did"], &asked["handle"], &asked["pending"]),
(&json!(contrib.account.did), &json!(them), &json!(false))
);
// Its line comment says on which side and lines, as written or not,
// and stands as its thread does; the review's own comment starts no
// thread, so is not resolved or not.
let (own, on_line) = (&s["comments"][0], &s["comments"][1]);
assert_eq!(own.get("resolved"), None, "{own}");
let at = json!({"path": "d", "side": "new", "line": 1, "startLine": 1});
assert_eq!(
(&on_line["line"], &on_line["resolved"]),
(&at, &json!(false))
);
// A pull, draft or not, says so where pulls are listed too.
let listed = o.json("pull_list", json!({"repo": repo, "query": "state:open"}));
let listed = listed
.as_array()
.unwrap()
.iter()
.find(|i| i["uri"] == mine.as_str());
assert_eq!(listed.unwrap()["draft"], false);
let theirs = &s["item"]["reviews"][0];
assert_eq!(theirs["handle"], them.as_str());
assert_eq!(
(&theirs["maintainer"], &theirs["outdated"]),
(&json!(false), &json!(false))
);
let not = "only the owner's and collaborators' approvals count";
assert_eq!(theirs["uncounted"], not);
// The review's own comment names itself, as its line comment names
// it; and says whom it mentions.
for said in s["comments"].as_array().unwrap() {
assert_eq!(said["review"], review.as_str(), "{said}");
}
let mention = &s["comments"][0]["facets"][0]["features"][0];
assert_eq!(mention["did"], owner.account.did.as_str(), "{s}");
// Asked first, it is blocked: the owner may merge it, but only by
// force, and is told how, in tools.
let checked = o.json(
"pull_merge",
json!({"repo": repo, "uri": mine, "check": true}),
);
assert_eq!(
(
&checked["state"],
&checked["reviews"],
&checked["may"]["merge"]
),
(&json!("blocked"), &json!("blocked"), &json!(true)),
"{checked}"
);
let uncounted = format!("@{them}'s approval does not count: {not}");
assert_eq!(checked["reviewsUncounted"], json!([{"text": uncounted}]));
let why = checked["reason"].as_str().unwrap();
assert!(why.contains(&uncounted), "{why}");
assert!(
why.ends_with(
"the owner, its author or not, merges past it with the pull_merge tool and force"
),
"{why}"
);
let (text, err) = o.call("pull_merge", json!({"repo": repo, "uri": mine}));
assert!(
err && text.contains("the comment_add tool and approve"),
"{text}"
);
// Someone who may not merge it is told so.
let theirs = c.json(
"pull_merge",
json!({"repo": repo, "uri": mine, "check": true}),
);
assert_eq!(theirs["may"]["merge"], false);
let why = theirs["reason"].as_str().unwrap();
assert!(why.contains("; and you may not merge it"), "{why}");
// The pull moves on: the approval is of an earlier head.
o.json("repo_commit", json!({"repo": repo, "branch": "mine", "message": "e", "changes": [{"path": "e", "content": "e\n"}]}));
wait_until(|| shown()["item"]["reviews"][0]["outdated"] == true);
// Forced, it merges, saying how, and its branch goes with it.
let merged = o.json(
"pull_merge",
json!({"repo": repo, "uri": mine, "force": true}),
);
assert_eq!(
(&merged["method"], &merged["deleted"], &merged["kept"]),
(&json!("squash"), &json!("mine"), &Value::Null),
"{merged}"
);
// Its branch gone, its diff is what the merge brought in, numbered
// as ever.
wait_until(|| shown()["item"]["state"] == "merged");
let diff = o.ok("pull_diff", json!({"repo": repo, "uri": mine}));
assert!(diff.contains("+++ b/d\n"), "{diff}");
assert!(diff.contains("@@ -0,0 +1 @@\n 1 | +e\n"), "{diff}");
// Reviewed again, the review before stands for nothing now, and says
// so; merged, no review of it is waited on.
let again = json!({"repo": repo, "uri": mine, "body": "Still fine", "approve": true});
c.json("comment_add", again);
wait_until(|| shown()["comments"].as_array().unwrap().len() == 3);
let s = shown();
assert_eq!(s["comments"][0]["superseded"], true, "{s}");
assert_eq!(s["comments"][2].get("superseded"), None, "{s}");
assert_eq!(s["item"]["reviewers"][0]["pending"], false, "{s}");
assert_eq!(o.json("branch_protect", unheld), json!([]));
let (text, err) = o.call("queue_list", json!({"repo": "nobody.test/x"}));
assert!(err, "{text}");
// Archived, it is read only, to anyone; unarchived, it takes issues
// again.
let archived = o.json("repo_archive", json!({"repo": repo}));
assert!(archived["archivedAt"].is_string(), "{archived}");
let (text, err) = c.call("issue_create", json!({"repo": repo, "title": "Late"}));
assert!(err && text.contains("is archived"), "{text}");
let (text, err) = o.call("repo_archive", json!({"repo": repo}));
assert!(err && text.contains("already archived"), "{text}");
let back = o.json("repo_archive", json!({"repo": repo, "undo": true}));
assert_eq!(back["archivedAt"], json!(null));
// Offered to the contributor, withdrawn, offered again, and taken up.
let them = contrib.account.handle.clone();
o.json("repo_transfer", json!({"repo": repo, "to": them}));
o.json("repo_transfer", json!({"repo": repo, "undo": true}));
let offered = o.json(
"repo_transfer",
json!({"repo": repo, "to": format!("{them}/kept")}),
);
assert_eq!(offered["offeredTo"], format!("knotless://{them}/kept"));
let moved = c.json("repo_accept", json!({"repo": repo}));
assert_eq!(
moved["url"],
format!("knotless://{}/kept", contrib.account.did)
);
for (tool, args) in [
("repo_transfer", json!({"repo": "nobody.test/x", "to": "x"})),
("repo_transfer", json!({"repo": "nobody.test/x"})),
(
"repo_transfer",
json!({"repo": "nobody.test/x", "to": "x", "undo": true}),
),
("repo_archive", json!({"repo": "nobody.test/x"})),
("repo_accept", json!({"repo": "nobody.test/x"})),
("repo_fork", json!({"repo": "nobody.test/x"})),
(
"pull_create",
json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "none", "title": "t"}),
),
(
"issue_create",
json!({"repo": "nobody.test/x", "title": "t"}),
),
("pull_list", json!({"repo": "nobody.test/x"})),
("issue_list", json!({"repo": "nobody.test/x"})),
("label_list", json!({"repo": "nobody.test/x"})),
("pull_diff", json!({"repo": repo, "uri": issue})),
(
"pull_show",
json!({"repo": repo, "uri": "at://did:plc:nobody/dev.knotless.issue/x"}),
),
// Only its author changes what a pull says, and a pull is closed,
// not deleted.
(
"pull_edit",
json!({"repo": repo, "uri": pull, "title": "x"}),
),
("comment_delete", json!({"repo": repo, "uri": issue})),
(
"pull_dismiss_review",
json!({"repo": repo, "uri": clash, "reviewer": format!("at://{}/dev.knotless.comment/x", owner.account.did), "reason": "r"}),
),
] {
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 mut operator = logged_in();
let (base, _appview, _proxy) = appview(&rt, &operator.account.did);
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);
operator.set("KNOTLESS_APPVIEW", &base);
let (o, c) = (Client::new(&owner), Client::new(&operator));
let repo = c_repo(&o.json("repo_create", json!({"name": "moderated"})));
o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
let issue = c.json("issue_create", json!({"repo": repo, "title": "Spam"}))["uri"]
.as_str()
.unwrap()
.to_string();
let items = || {
o.json("issue_list", json!({"repo": repo}))
.as_array()
.unwrap()
.len()
};
wait_until(|| items() == 1);
let hidden = o.json("mod_hide", json!({"repo": repo, "uri": issue}));
assert_eq!(hidden["hidden"], true);
wait_until(|| items() == 0);
o.json(
"mod_hide",
json!({"repo": repo, "uri": issue, "undo": true}),
);
wait_until(|| items() == 1);
let them = operator.account.handle.clone();
assert_eq!(
o.json("mod_block", json!({"handle": them}))["blocked"],
true
);
wait_until(|| items() == 0);
o.json("mod_block", json!({"handle": them, "undo": true}));
wait_until(|| items() == 1);
o.json("mod_block", json!({"handle": them, "repo": repo}));
wait_until(|| items() == 0);
o.json(
"mod_block",
json!({"handle": them, "repo": repo, "undo": true}),
);
wait_until(|| items() == 1);
let report = c.json(
"mod_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(
"mod_report",
json!({"subject": owner.account.handle, "reason": "rude"}),
);
let reports = c.json("admin_reports", json!({}));
assert_eq!(reports["reports"].as_array().unwrap().len(), 2);
let stats = c.json("admin_stats", json!({"days": 1}));
assert_eq!(stats["days"].as_array().unwrap().len(), 1, "{stats}");
// Its operator reads its logs.
let logs = c.json(
"admin_logs",
json!({"level": "warn", "min_ms": 1, "limit": 5}),
);
assert!(logs["lines"].is_array(), "{logs}");
let down = c.json("admin_takedown", json!({"subject": issue}));
assert_eq!(down["takedown"]["applied"], true);
wait_until(|| items() == 0);
let status = c.json("admin_status", json!({"subject": issue}));
assert_eq!(status["takedown"]["applied"], true);
c.json("admin_takedown", json!({"subject": issue, "undo": true}));
wait_until(|| items() == 1);
for (who, tool, args) in [
(&c, "mod_hide", json!({"repo": repo, "uri": issue})),
(&o, "mod_block", json!({"handle": "nobody.test"})),
(
&c,
"mod_report",
json!({"subject": repo, "reason": "boring"}),
),
(&o, "admin_takedown", json!({"subject": issue})),
(&o, "admin_status", json!({"subject": issue})),
(&o, "admin_reports", json!({})),
(&o, "admin_stats", json!({})),
(&o, "admin_logs", json!({"trace": "t"})),
] {
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()
}
#[test]
fn agents_star() {
let owner = logged_in();
let o = Client::new(&owner);
let repo = c_repo(&o.json("repo_create", json!({"name": "shiny"})));
let fan = logged_in();
let f = Client::new(&fan);
let starred = f.json("repo_star", json!({"repo": repo}));
assert_eq!(starred["starred"], true);
let uri = starred["uri"].as_str().unwrap().to_string();
assert_eq!(
f.json("repo_star", json!({"repo": repo}))["uri"],
uri.as_str()
);
let mine = f.json("repo_stars", json!({}));
assert_eq!(mine["stars"][0]["uri"], uri.as_str());
let shown = format!("knotless://{}/shiny", owner.account.handle);
assert_eq!(mine["stars"][0]["repo"], shown);
let theirs = o.json("repo_stars", json!({"handle": fan.account.handle}));
assert_eq!(theirs, mine);
let unstarred = f.json("repo_star", json!({"repo": repo, "undo": true}));
assert_eq!(unstarred, json!({"starred": false, "uri": null}));
assert_eq!(f.json("repo_stars", json!({})), json!({"stars": []}));
}
/// An agent follows an account, sees whom it follows, and unfollows it;
/// who follows an account takes an appview.
#[test]
fn agents_follow() {
let owner = logged_in();
let fan = logged_in();
let f = Client::new(&fan);
let account = owner.account.handle.clone();
let followed = f.json("account_follow", json!({"handle": account}));
assert_eq!(followed["following"], true);
let uri = followed["uri"].as_str().unwrap().to_string();
assert_eq!(
f.json("account_follow", json!({"handle": account}))["uri"],
uri.as_str()
);
let mine = f.json("account_follows", json!({}));
assert_eq!(mine["follows"][0]["uri"], uri.as_str());
assert_eq!(mine["follows"][0]["handle"], account.as_str());
let theirs = f.json("account_follows", json!({"handle": fan.account.handle}));
assert_eq!(theirs, mine);
let (why, err) = f.call("account_followers", json!({}));
assert!(err && why.contains("KNOTLESS_APPVIEW"), "{why}");
let unfollowed = f.json("account_follow", json!({"handle": account, "undo": true}));
assert_eq!(unfollowed, json!({"following": false, "uri": null}));
assert_eq!(f.json("account_follows", json!({})), json!({"follows": []}));
}
/// An owner hears, from the appview, of what others did that concerns it:
/// a star, a follow, an issue on its repository and a comment on its
/// issue; new until it lists them, over MCP or the command line.
#[test]
fn agents_hear_what_others_did() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt, "");
let mut owner = logged_in();
owner.set("KNOTLESS_APPVIEW", &base);
owner.set("KNOTLESS_DID_WEB_SCHEME", "http");
let mut fan = logged_in();
fan.set("KNOTLESS_APPVIEW", &base);
fan.set("KNOTLESS_DID_WEB_SCHEME", "http");
let (o, f) = (Client::new(&owner), Client::new(&fan));
let repo = c_repo(&o.json("repo_create", json!({"name": "heard"})));
o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
let mine = o.json("issue_create", json!({"repo": repo, "title": "Mine"}))["uri"]
.as_str()
.unwrap()
.to_string();
f.json("repo_star", json!({"repo": repo}));
f.json("account_follow", json!({"handle": owner.account.handle}));
f.json("issue_create", json!({"repo": repo, "title": "Theirs"}));
f.json(
"comment_add",
json!({"repo": repo, "uri": mine, "body": "me too"}),
);
// Listed without being marked seen, until all four are there.
let peek = || o.json("notification_list", json!({"peek": true}))["notifications"].clone();
wait_until(|| peek().as_array().unwrap().len() == 4);
let heard = peek();
let mut reasons: Vec<&str> = heard
.as_array()
.unwrap()
.iter()
.map(|n| n["reason"].as_str().unwrap())
.collect();
reasons.sort();
assert_eq!(reasons, ["comment", "follow", "issue", "star"]);
// Who follows the owner, as the appview counts them.
let followers = o.json("account_followers", json!({}));
assert_eq!(
followers["followers"][0]["handle"],
fan.account.handle.as_str()
);
assert!(heard.as_array().unwrap().iter().all(|n| n["new"] == true));
let comment = heard
.as_array()
.unwrap()
.iter()
.find(|n| n["reason"] == "comment");
let comment = comment.unwrap();
assert_eq!(comment["subject"], mine.as_str());
assert_eq!(comment["author"], fan.account.handle.as_str());
let about = format!("knotless://{}/heard \"Mine\"", owner.account.handle);
assert_eq!(comment["about"], about);
// Each names its repository, for pull_show, and what it is about: the
// issue itself, the repository starred, and for a follow, nothing.
let named = format!("{}/heard", owner.account.handle);
let of = |reason: &str| {
let mut all = heard.as_array().unwrap().iter();
all.find(|n| n["reason"] == reason).unwrap().clone()
};
let issue = of("issue");
assert_eq!(
(&issue["subject"], &issue["repo"]),
(&issue["uri"], &json!(named))
);
let starred = format!("at://{}/dev.knotless.repo/heard", owner.account.did);
assert_eq!(of("star")["subject"], starred);
assert_eq!(
(&of("follow")["subject"], &of("follow")["repo"]),
(&Value::Null, &Value::Null)
);
// Of one kind, on the command line and here, marking none seen.
let (code, lines) = owner.cli(
&["notification", "list", "--kind", "stars-and-follows"],
b"",
);
assert_eq!((code, lines.lines().count()), (0, 2), "{lines}");
let comments = o.json("notification_list", json!({"kind": "comments"}));
assert_eq!(comments["notifications"][0]["reason"], "comment");
assert_eq!(comments["notifications"].as_array().unwrap().len(), 1);
assert_eq!(comments["markedSeen"], false);
assert_eq!(owner.cli(&["notification", "list", "--kind"], b"").0, 1);
// The command line peeks as well; listing marks them seen.
let (code, lines) = owner.cli(&["notification", "list", "--peek"], b"");
assert_eq!(code, 0, "{lines}");
assert_eq!(lines.lines().filter(|l| l.starts_with("new\t")).count(), 4);
assert_eq!(owner.cli(&["notification", "list", "--bogus"], b"").0, 1);
assert_eq!(owner.cli(&["notification", "list"], b"").0, 0);
let listed = o.json("notification_list", json!({}));
assert_eq!(listed["markedSeen"], true);
let listed = listed["notifications"].clone();
assert!(listed.as_array().unwrap().iter().all(|n| n["new"] == false));
let peeked = o.json("notification_list", json!({"peek": true}));
assert_eq!(peeked["markedSeen"], false);
// Having commented, the fan hears of what is said after in the thread.
o.json(
"comment_add",
json!({"repo": repo, "uri": mine, "body": "thanks"}),
);
let theirs = || f.json("notification_list", json!({"peek": true}))["notifications"].clone();
wait_until(|| theirs().as_array().unwrap().len() == 1);
let reply = &theirs()[0];
assert_eq!(
(&reply["reason"], &reply["subject"]),
(&json!("comment"), &json!(mine))
);
assert_eq!(reply["author"], owner.account.handle.as_str());
// A review of the owner's pull that names them, on its lines too, is
// heard of once: as a review, mentioning them, its line comments
// counted.
let side = json!([{"path": "b", "content": "b\n"}]);
o.json(
"repo_commit",
json!({"repo": repo, "branch": "side", "message": "b", "changes": side}),
);
let opened = json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "side", "title": "Add b"});
let pull = o.json("pull_create", opened)["uri"]
.as_str()
.unwrap()
.to_string();
let body = format!("Not yet, @{}", owner.account.handle);
let lines = json!([{"path": "b", "line": 1, "body": "Why b?"}, {"path": "b", "line": 1, "body": "And here"}]);
let review = json!({"repo": repo, "uri": pull, "body": body, "changes": true, "lines": lines});
f.json("comment_add", review);
let on = |n: &&Value| n["subject"] == pull.as_str();
wait_until(|| peek().as_array().unwrap().iter().any(|n| on(&n)));
let heard = peek();
let of_it: Vec<&Value> = heard.as_array().unwrap().iter().filter(on).collect();
assert_eq!(of_it.len(), 1, "{heard}");
let said = (
&of_it[0]["reason"],
&of_it[0]["verdict"],
&of_it[0]["mentioned"],
);
assert_eq!(said, (&json!("comment"), &json!("changes"), &json!(true)));
assert_eq!(of_it[0]["lineComments"], 2);
// Without an appview there is no one to ask.
let mut alone = logged_in();
alone.set("KNOTLESS_APPVIEW", "");
let (why, err) = Client::new(&alone).call("notification_list", json!({}));
assert!(err && why.contains("KNOTLESS_APPVIEW"), "{why}");
// Whether the appview is well, asked of it, or of one named.
let status = o.json("status", json!({"repo": repo}));
assert!(status["ok"].is_boolean(), "{status}");
let named = o.json("status", json!({"server": base}));
assert!(named["ok"].is_boolean(), "{named}");
let (why, err) = Client::new(&alone).call("status", json!({}));
assert!(err && why.contains("give a server"), "{why}");
}
/// An agent watches a repository it has no part in and hears of an
/// issue opened on it; subscribes to the issue and hears of its comments;
/// mutes it and hears nothing of it; and lists what it chose, over MCP or
/// the command line.
#[test]
fn agents_watch_subscribe_and_mute() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt, "");
let [mut owner, mut fan, mut watcher] = [logged_in(), logged_in(), logged_in()];
for s in [&mut owner, &mut fan, &mut watcher] {
s.set("KNOTLESS_APPVIEW", &base);
s.set("KNOTLESS_DID_WEB_SCHEME", "http");
}
let (o, f, w) = (
Client::new(&owner),
Client::new(&fan),
Client::new(&watcher),
);
let repo = c_repo(&o.json("repo_create", json!({"name": "watched"})));
o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
let record = format!("at://{}/dev.knotless.repo/watched", owner.account.did);
let watching = w.json("notification_watch", json!({"repo": repo}));
assert_eq!(
(&watching["subject"], &watching["level"]),
(&json!(record), &json!("all"))
);
let issue = f.json("issue_create", json!({"repo": repo, "title": "Theirs"}))["uri"]
.as_str()
.unwrap()
.to_string();
let peek = || w.json("notification_list", json!({"peek": true}))["notifications"].clone();
let heard = |reason: &str| {
let all = peek();
let all = all.as_array().unwrap().iter();
all.filter(|n| n["reason"] == reason).count()
};
wait_until(|| heard("watching") == 1);
assert_eq!(peek()[0]["subject"], issue.as_str());
// Subscribed, its comments are heard of as such, not as watching.
let subscribed = w.json("notification_subscribe", json!({"uri": issue}));
assert_eq!(subscribed["level"], "subscribed");
f.json(
"comment_add",
json!({"repo": repo, "uri": issue, "body": "more"}),
);
wait_until(|| heard("subscribed") == 1);
assert_eq!(heard("watching"), 1);
// Muted on the command line, nothing of it is heard.
let (code, said) = watcher.cli(&["notification", "mute", &issue], b"");
assert_eq!((code, said.trim()), (0, format!("muted\t{issue}").as_str()));
assert!(peek().as_array().unwrap().is_empty());
let url = format!("knotless://{}/watched", owner.account.handle);
let (code, said) = watcher.cli(&["notification", "watch", &url, "releases"], b"");
assert_eq!(
(code, said.trim()),
(0, format!("releases\t{record}").as_str())
);
let (code, listed) = watcher.cli(&["notification", "subscriptions"], b"");
assert_eq!((code, listed.lines().count()), (0, 2), "{listed}");
assert!(listed.starts_with("releases\t"), "{listed}");
let all = w.json("notification_subscriptions", json!({}));
assert_eq!(all["subscriptions"][1]["subject"], issue.as_str());
assert!(all["subscriptions"][1]["createdAt"].is_string());
// Each forgotten: the issue heard of again as watching does.
let (code, said) = watcher.cli(&["notification", "unsubscribe", &issue], b"");
assert_eq!(
(code, said.trim()),
(0, format!("participating\t{issue}").as_str())
);
let (code, said) = watcher.cli(&["notification", "subscribe", &issue], b"");
assert_eq!((code, said.split('\t').next()), (0, Some("subscribed")));
let (code, _) = watcher.cli(&["notification", "watch", &url], b"");
assert_eq!(code, 0);
let forgot = w.json(
"notification_watch",
json!({"repo": repo, "level": "participating"}),
);
assert_eq!(forgot.get("createdAt"), None);
let muted = w.json("notification_mute", json!({"uri": issue}));
assert_eq!(muted["level"], "muted");
// Refused for what is no repository, pull or issue, or a level wrong
// for it.
let (why, err) = w.call("notification_mute", json!({"uri": record}));
assert!(err && why.contains("no level"), "{why}");
let undone = w.json("notification_unsubscribe", json!({"uri": issue}));
assert_eq!(undone["level"], "participating");
let (code, _) = watcher.cli(&["notification", "mute"], b"");
assert_eq!(code, 1);
}
/// What only the command line did before, as tools: an owner's
/// house-keeping, from making a repository to deleting it.
#[test]
fn agents_keep_house() {
let s = logged_in();
let c = Client::new(&s);
let made = c.json("repo_create", json!({"name": "kept", "runners": []}));
assert_eq!(made["runners"], json!([]));
let repo = c_repo(&made);
for (n, path) in ["a", "b"].iter().enumerate() {
let change = json!([{"path": path, "content": format!("{n}\n")}]);
c.json(
"repo_commit",
json!({"repo": repo, "branch": "trunk", "message": path, "changes": change}),
);
}
assert_eq!(c.json("branch_rules", json!({"repo": repo})), json!([]));
c.json("branch_protect", json!({"repo": repo, "branch": "trunk"}));
let rules = c.json("branch_rules", json!({"repo": repo}));
assert_eq!(rules[0]["branch"], "trunk");
// With no grace period here, both packs fold into one.
let compacted = c.json("repo_compact", json!({"repo": repo}));
let counted = (&compacted["before"], &compacted["removed"]);
assert_eq!(counted, (&json!(2), &json!(2)), "{compacted}");
assert_eq!(c.json("repo_storage", json!({"repo": repo}))["packs"], 1);
// Asking for code owners where the owner alone owns a path warns
// that their own pull of it merges only by force.
let handle = s.account.handle.clone();
let owners = json!([{"path": "CODEOWNERS", "content": format!("* @{handle}\n")}]);
c.json(
"repo_commit",
json!({"repo": repo, "branch": "trunk", "message": "owners", "changes": owners}),
);
let asks = json!({"repo": repo, "branch": "trunk", "reviews": 1, "code_owners": true});
let rules = c.json("branch_protect", asks);
let warned = rules[0]["warnings"][0].as_str().unwrap();
assert!(
warned.starts_with(&format!("@{handle} alone owns *, ")),
"{rules}"
);
assert_eq!(c.json("branch_rules", json!({"repo": repo})), rules);
let (why, err) = c.call("repo_delete", json!({"repo": repo, "confirm": "nope"}));
assert!(err && why.contains("kept"), "{why}");
let deleted = c.json("repo_delete", json!({"repo": repo, "confirm": "kept"}));
let shown = format!("knotless://{}/kept", s.account.handle);
assert_eq!(deleted["deleted"], shown);
let (why, err) = c.call("repo_refs", json!({"repo": repo}));
assert!(err, "{why}");
}
/// What takes an appview, over the tools and the command line alike:
/// finding an account by its handle, a pull's diff, renaming the branch it
/// targets, and how a triager labels.
#[test]
fn agents_and_the_command_line_find_and_rename() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt, "");
let mut owner = logged_in();
owner.set("KNOTLESS_APPVIEW", &base);
owner.set("KNOTLESS_DID_WEB_SCHEME", "http");
let o = Client::new(&owner);
let repo = c_repo(&o.json("repo_create", json!({"name": "found"})));
o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
o.json("repo_commit", json!({"repo": repo, "branch": "topic", "message": "b", "changes": [{"path": "b", "content": "b\n"}]}));
o.json("branch_create", json!({"repo": repo, "name": "side"}));
let handle = owner.account.handle.clone();
let find = || o.json("account_search", json!({"text": handle, "limit": 50}))["owners"].clone();
let mine = |v: &Value| v["owner"]["handle"] == handle.as_str();
wait_until(|| find().as_array().unwrap().iter().any(mine));
let found = find();
let me = found.as_array().unwrap().iter().find(|v| mine(v));
assert_eq!(me.unwrap()["repoCount"], 1);
let (code, out) = owner.cli(&["account", "search", &handle, "--limit", "50"], b"");
assert_eq!(code, 0, "{out}");
assert!(out.lines().any(|l| l == format!("{handle}\t1")), "{out}");
assert_eq!(
owner.cli(&["account", "search", &handle, "--bogus"], b"").0,
1
);
assert_eq!(owner.cli(&["account", "search", &handle], b"").0, 0);
let mut alone = logged_in();
alone.set("KNOTLESS_APPVIEW", "");
let (why, err) = Client::new(&alone).call("account_search", json!({"text": handle}));
assert!(err && why.contains("KNOTLESS_APPVIEW"), "{why}");
let url = format!("knotless://{repo}");
let (code, pull) = owner.cli(
&["pull", "create", &url, "side", &url, "topic", "Topic"],
b"",
);
assert_eq!(code, 0, "{pull}");
let pulls = || o.json("pull_list", json!({"repo": repo}));
wait_until(|| pulls().as_array().unwrap().len() == 1);
let (code, diff) = owner.cli(&["pull", "diff", &url, &pull], b"");
assert!(code == 0 && diff.contains("+b"), "{diff}");
let (code, out) = owner.cli(&["branch", "rename", &url, "side", "next"], b"");
assert_eq!(code, 0, "{out}");
assert_eq!(out, format!("renamed side to next\nretargeted {pull}"));
let (code, sha) = owner.cli(&["tag", "create", &url, "v1", "next"], b"");
assert!(code == 0 && sha.len() == 40, "{sha}");
let (code, out) = owner.cli(&["tag", "delete", &url, "v1"], b"");
assert_eq!((code, out.as_str()), (0, "deleted v1"));
let scores = o.json("triager_eval", json!({"repo": repo, "handle": handle}));
assert!(scores.is_array(), "{scores}");
}
/// An agent claims the addresses its commits show as its by, and lists
/// whom it blocks, as the command line does.
#[test]
fn agents_keep_their_settings() {
let s = logged_in();
let them = logged_in();
let c = Client::new(&s);
assert_eq!(c.json("account_email", json!({})), json!({"claimed": 0}));
let add = json!({"address": "Agent@Example.com"});
let added = c.json("account_email_add", add.clone());
assert_eq!(added, json!({"changed": true, "claimed": 1}));
assert_eq!(c.json("account_email_add", add)["changed"], false);
let (code, out) = s.cli(&["account", "email"], b"");
assert_eq!((code, out.as_str()), (0, "1 address claimed"));
let gone = c.json(
"account_email_remove",
json!({"address": "agent@example.com"}),
);
assert_eq!(gone, json!({"changed": true, "claimed": 0}));
let (why, err) = c.call("account_email_add", json!({"address": "nowhere"}));
assert!(err && why.contains("no email address"), "{why}");
assert_eq!(c.json("mod_blocks", json!({})), json!({"blocks": []}));
let handle = them.account.handle.clone();
c.json("mod_block", json!({"handle": handle}));
let repo = c_repo(&c.json("repo_create", json!({"name": "kept"})));
c.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
c.json("mod_block", json!({"handle": handle, "repo": repo}));
let listed = c.json("mod_blocks", json!({}));
let blocks = listed["blocks"].as_array().unwrap();
assert_eq!(blocks.len(), 2, "{listed}");
assert!(blocks.iter().all(|b| b["did"] == them.account.did.as_str()));
assert!(blocks.iter().all(|b| b["handle"] == handle.as_str()));
let named = format!("at://{}/dev.knotless.repo/kept", s.account.did);
assert_eq!(blocks.iter().filter(|b| b["repo"] == named).count(), 1);
assert_eq!(blocks.iter().filter(|b| b["repo"].is_null()).count(), 1);
// Anyone's, by handle, as their repo has them.
let mine = json!({"handle": s.account.handle});
assert_eq!(Client::new(&them).json("mod_blocks", mine), listed);
let (code, out) = s.cli(&["mod", "blocks"], b"");
assert_eq!((code, out.lines().count()), (0, 2), "{out}");
}
/// What an account hears of is its to choose, kept by the appview: a
/// reason turned off, or anyone it does not follow, is not heard of, over
/// MCP and the command line alike.
#[test]
fn agents_choose_what_they_hear() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt, "");
let mut owner = logged_in();
owner.set("KNOTLESS_APPVIEW", &base);
owner.set("KNOTLESS_DID_WEB_SCHEME", "http");
let mut fan = logged_in();
fan.set("KNOTLESS_APPVIEW", &base);
fan.set("KNOTLESS_DID_WEB_SCHEME", "http");
let (o, f) = (Client::new(&owner), Client::new(&fan));
let all = o.json("notification_settings", json!({}));
assert_eq!(all["followed_only"], false);
let listed = all["reasons"].as_array().unwrap();
let reasons: Vec<&str> = listed
.iter()
.map(|r| r["reason"].as_str().unwrap())
.collect();
assert_eq!(reasons[..5], ["star", "follow", "pull", "issue", "comment"]);
assert!(listed.iter().all(|r| r["enabled"] == true));
let off = o.json("notification_settings", json!({"off": ["star"]}));
assert_eq!(
off["reasons"][0],
json!({"reason": "star", "enabled": false})
);
assert_eq!(off["reasons"][1]["enabled"], true);
let both = json!({"followed_only": true, "anyone": true});
let (why, err) = o.call("notification_settings", both);
assert!(err && why.contains("not both"), "{why}");
let only = o.json("notification_settings", json!({"followed_only": true}));
assert_eq!(only["followed_only"], true);
let anyone = o.json("notification_settings", json!({"anyone": true}));
assert_eq!(anyone["followed_only"], false);
let (why, err) = o.call("notification_settings", json!({"off": ["stars"]}));
assert!(err && why.contains("is no reason"), "{why}");
// A star, unheard of; the follow after it, heard.
let repo = c_repo(&o.json("repo_create", json!({"name": "quiet"})));
o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
f.json("repo_star", json!({"repo": repo}));
f.json("account_follow", json!({"handle": owner.account.handle}));
let peek = || o.json("notification_list", json!({"peek": true}))["notifications"].clone();
wait_until(|| !peek().as_array().unwrap().is_empty());
let heard = peek();
assert_eq!(heard.as_array().unwrap().len(), 1, "{heard}");
assert_eq!(heard[0]["reason"], "follow");
// The command line, the same: only from accounts followed, which the
// fan is not, then from anyone, with stars again.
let settings = |args: &[&str]| owner.cli(&[&["notification", "settings"], args].concat(), b"");
let (code, out) = settings(&["--followed-only", "--off", "issue"]);
assert_eq!(code, 0, "{out}");
assert!(out.contains("issue\toff"), "{out}");
assert!(out.ends_with("from\taccounts you follow"), "{out}");
assert!(peek().as_array().unwrap().is_empty());
let (code, out) = settings(&["--anyone", "--on", "star"]);
assert_eq!(code, 0, "{out}");
assert!(out.starts_with("star\ton\nfollow\ton\n"), "{out}");
assert!(out.ends_with("from\tanyone"), "{out}");
assert_eq!(peek().as_array().unwrap().len(), 2);
assert_eq!(settings(&[]).1, out);
assert_eq!(settings(&["--bogus"]).0, 1);
// Each account's are its own.
let theirs = f.json("notification_settings", json!({}));
assert_eq!(theirs["reasons"], all["reasons"]);
}
/// An agent makes a theme for the pages: a palette checked for contrast
/// as the pages check their own, published for anyone to choose, chosen,
/// found, and deleted; the command line does the same.
#[test]
fn agents_make_themes() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt, "");
let mut maker = logged_in();
maker.set("KNOTLESS_APPVIEW", &base);
maker.set("KNOTLESS_DID_WEB_SCHEME", "http");
let mut other = logged_in();
other.set("KNOTLESS_APPVIEW", &base);
other.set("KNOTLESS_DID_WEB_SCHEME", "http");
let (m, o) = (Client::new(&maker), Client::new(&other));
let paper = json!({"colorScheme": "light", "background": "#ffffff", "text": "#1f2328",
"primary": "#1f6feb", "accent": "#0969da"});
let mut pale = paper.clone();
pale["textMuted"] = json!("#eeeeee");
let mine = json!({"handle": maker.account.handle});
assert_eq!(m.json("theme_list", mine.clone()), json!({"themes": []}));
// Checked alone: what falls short, named as the palette names it.
let fine = m.json("theme_check", json!({"palette": paper}));
assert_eq!(fine, json!({"readable": true, "shortfalls": []}));
let short = m.json("theme_check", json!({"palette": pale}));
assert_eq!(short["readable"], false);
assert_eq!(short["shortfalls"][0]["text"], "textMuted");
assert_eq!(short["shortfalls"][0]["on"], "background");
let refused = |c: &Client, tool: &str, args: Value, why: &str| {
let (said, err) = c.call(tool, args);
assert!(err && said.contains(why), "{said}");
};
refused(
&m,
"theme_check",
json!({"palette": {"colorScheme": "dark"}}),
"background",
);
refused(&m, "theme_use", json!({"theme": "palette"}), "none is");
refused(&m, "theme_use", json!({"theme": "sepia"}), "is no theme");
let hard = json!({"name": "Pale", "palette": pale});
refused(&m, "theme_publish", hard, "hard to read");
let blank = json!({"name": " ", "palette": paper});
refused(&m, "theme_publish", blank, "a theme's name");
assert!(maker.records("dev.knotless.theme").is_empty());
// Published and used at once; found by anyone, as the appview lists it.
let made = m.json(
"theme_publish",
json!({"name": "Quokka paper", "palette": paper, "use": true}),
);
assert_eq!(made["using"], "Quokka paper");
let uri = made["uri"].as_str().unwrap().to_string();
assert!(
uri.starts_with(&format!("at://{}/dev.knotless.theme/", maker.account.did)),
"{uri}"
);
let found = || o.json("theme_list", json!({"find": "quokka"}))["themes"].clone();
wait_until(|| !found().as_array().unwrap().is_empty());
assert_eq!(found()[0]["uri"], uri.as_str());
assert_eq!(found()[0]["creator"], maker.account.handle.as_str());
assert_eq!(found()[0]["palette"], paper);
let theirs = o.json("theme_list", mine);
assert_eq!(theirs["themes"][0]["name"], "Quokka paper");
// Chosen by another, as published; or one of the pages', or the system's.
for (choice, drawn) in [
(uri.as_str(), "Quokka paper"),
("mocha", "mocha"),
("system", "the system's light or dark"),
] {
assert_eq!(
o.json("theme_use", json!({"theme": choice})),
json!({"using": drawn})
);
}
let own = o.json("theme_use", json!({"theme": "palette", "palette": paper}));
assert_eq!(own, json!({"using": "your own"}));
let gone = format!(
"at://{}/dev.knotless.theme/3zzzzzzzzzzzz",
maker.account.did
);
refused(&o, "theme_use", json!({"theme": gone}), "no such theme");
refused(
&o,
"theme_use",
json!({"theme": "at://nowhere"}),
"is no theme",
);
// A record no palette is, as any client could write.
maker.put_record("dev.knotless.theme", "3broken", json!({"$type": "dev.knotless.theme", "name": "Broken", "palette": {"colorScheme": "sepia"}, "createdAt": "2026-01-01T00:00:00Z"}));
let broken = format!("at://{}/dev.knotless.theme/3broken", maker.account.did);
refused(&o, "theme_use", json!({"theme": broken}), "is no palette");
// Only its maker deletes it.
refused(&o, "theme_delete", json!({"uri": uri}), "no theme of yours");
assert_eq!(
m.json("theme_delete", json!({"uri": uri})),
json!({"deleted": uri})
);
assert_eq!(maker.records("dev.knotless.theme").len(), 1);
let plain = m.json(
"theme_publish",
json!({"name": "Plain paper", "palette": paper}),
);
assert!(plain.get("using").is_none(), "{plain}");
let plain = plain["uri"].as_str().unwrap().to_string();
m.json("theme_delete", json!({"uri": plain}));
// The command line, the same.
let file = maker.path("night.json");
let night = json!({"colorScheme": "dark", "background": "#0d1117", "text": "#e6edf3",
"primary": "#2f81f7", "accent": "#58a6ff"});
std::fs::write(&file, night.to_string()).unwrap();
let file = file.to_str().unwrap();
let cli = |args: &[&str], stdin: &[u8]| maker.cli(args, stdin);
assert_eq!(
cli(&["theme", "check", file], b""),
(0, "every text reads at 4.5 to 1 or more".into())
);
let record = json!({"name": "Pale", "palette": pale}).to_string();
// Refused as an error, on stderr, as the binary says it.
let (ok, _, why) = maker.knotless(&["theme", "check", "-"], record.as_bytes());
assert!(!ok && why.contains("textMuted on background"), "{why}");
let (code, uri) = cli(
&["theme", "publish", "Wombat night", "-", "--use"],
night.to_string().as_bytes(),
);
assert_eq!(code, 0, "{uri}");
assert!(uri.contains("/dev.knotless.theme/"), "{uri}");
// Published alone, not used; and not with what it does not know.
let (code, alone) = cli(
&["theme", "publish", "Wombat dusk", "-"],
night.to_string().as_bytes(),
);
assert_eq!(code, 0, "{alone}");
assert_eq!(
cli(&["theme", "delete", &alone], b""),
(0, "deleted".into())
);
let bogus = cli(
&["theme", "publish", "Wombat", "-", "--bogus"],
night.to_string().as_bytes(),
);
assert_eq!(bogus.0, 1);
assert_eq!(
cli(&["theme", "use", "dracula"], b""),
(0, "drawn in dracula".into())
);
assert_eq!(
cli(&["theme", "use", file], b""),
(0, "drawn in your own".into())
);
assert_eq!(
cli(&["theme", "use", "-"], night.to_string().as_bytes()),
(0, "drawn in your own".into())
);
let named = || cli(&["theme", "list", "--find", "wombat"], b"");
wait_until(|| named().1.starts_with("Wombat night\t"));
assert_eq!(
named().1,
format!("Wombat night\t{}\t{uri}", maker.account.handle)
);
let (code, all) = cli(&["theme", "list"], b"");
assert!(code == 0 && all.contains(&uri), "{all}");
let (code, mine) = cli(&["theme", "list", &maker.account.handle], b"");
assert!(code == 0 && mine.contains(&uri), "{mine}");
assert_eq!(cli(&["theme", "delete", &uri], b""), (0, "deleted".into()));
let wrong: [&[&str]; 8] = [
&["themes", "--bogus"],
&["themes", "a.test", "b.test"],
&["theme", "frob"],
&["theme", "publish", "Night", file, "--bogus"],
&["theme", "check", "/nowhere.json"],
&["theme", "check", "-"],
&["theme", "use", "sepia"],
&["theme"],
];
for args in wrong {
assert_eq!(cli(args, b"not json").0, 1, "{args:?}");
}
}
/// An agent reacts to an issue and a comment, sees the counts the appview
/// keeps in issue_show, and takes a reaction back; the command line does
/// the same.
#[test]
fn agents_react() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt, "");
let mut owner = logged_in();
owner.set("KNOTLESS_APPVIEW", &base);
let mut fan = logged_in();
fan.set("KNOTLESS_APPVIEW", &base);
let (o, f) = (Client::new(&owner), Client::new(&fan));
let repo = c_repo(&o.json("repo_create", json!({"name": "reacted"})));
o.json("repo_commit", json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a\n"}]}));
let issue = o.json("issue_create", json!({"repo": repo, "title": "Shiny"}))["uri"]
.as_str()
.unwrap()
.to_string();
let said = o.json(
"comment_add",
json!({"repo": repo, "uri": issue, "body": "look"}),
)["uri"]
.as_str()
.unwrap()
.to_string();
let rocket = json!({"repo": repo, "uri": issue, "content": "rocket"});
let reacted = f.json("issue_react", rocket.clone());
assert_eq!(reacted["reacted"], true);
let uri = reacted["uri"].as_str().unwrap().to_string();
assert!(uri.starts_with(&format!("at://{}/dev.knotless.reaction/", fan.account.did)));
assert_eq!(f.json("issue_react", rocket)["uri"], uri.as_str());
assert_eq!(fan.records("dev.knotless.reaction").len(), 1);
let hooray = json!({"repo": repo, "uri": said, "content": "hooray"});
let on_comment = f.json("comment_react", hooray);
assert_eq!(on_comment["reacted"], true);
// The command line reacts too.
let url = format!("knotless://{repo}");
let (code, out) = fan.cli(&["comment", "react", &url, &said, "hooray"], b"");
assert_eq!(code, 0, "{out}");
assert!(out.starts_with("at://"), "{out}");
let (code, out) = owner.cli(&["comment", "react", &url, &said, "hooray"], b"");
assert_eq!(code, 0, "{out}");
// Both show what the appview counts, once it has them.
wait_until(|| {
let shown = o.json("issue_show", json!({"repo": repo, "uri": issue}));
shown["reactions"] == json!({"rocket": 1})
&& shown["comments"][0]["reactions"] == json!({"hooray": 2})
});
let (code, shown) = owner.cli(&["pull", "show", &url, &issue], b"");
assert_eq!(code, 0, "{shown}");
assert!(shown.contains("reactions: rocket 1"), "{shown}");
assert!(shown.contains("reactions: hooray 2"), "{shown}");
// Taken back, over MCP and on the command line.
let undo = json!({"repo": repo, "uri": issue, "content": "rocket", "undo": true});
assert_eq!(
f.json("issue_react", undo),
json!({"reacted": false, "uri": null})
);
let undo = [
"comment",
"react",
url.as_str(),
said.as_str(),
"hooray",
"--undo",
];
assert_eq!(fan.cli(&undo, b""), (0, "taken back".to_string()));
assert!(fan.records("dev.knotless.reaction").is_empty());
wait_until(|| {
let shown = o.json("issue_show", json!({"repo": repo, "uri": issue}));
shown["reactions"].is_null() && shown["comments"][0]["reactions"] == json!({"hooray": 1})
});
// One of the eight, to a pull, an issue or a comment on the repository.
let tada = json!({"repo": repo, "uri": issue, "content": "tada"});
let (why, err) = f.call("issue_react", tada);
assert!(err && why.contains("+1 -1 laugh"), "{why}");
let record = format!("at://{}/dev.knotless.repo/reacted", owner.account.did);
let (why, err) = f.call(
"pull_react",
json!({"repo": repo, "uri": record, "content": "heart"}),
);
assert!(
err && why.contains("is not a pull, an issue or a comment"),
"{why}"
);
let missing = format!("at://{}/dev.knotless.issue/3zzzzzzzzzzzz", fan.account.did);
let (why, err) = f.call(
"comment_react",
json!({"repo": repo, "uri": missing, "content": "heart"}),
);
assert!(err && why.contains("no pull, issue or comment"), "{why}");
let bogus = [
"comment",
"react",
url.as_str(),
said.as_str(),
"heart",
"--bogus",
];
assert_eq!(fan.cli(&bogus, b"").0, 1);
if net().spaces {
// On a private repository, a reaction is in its space, with what
// it is about, and counted from there: none in the account's own.
o.json("repo_create", json!({"name": "hush", "private": true}));
let hush = format!("{}/hush", owner.account.handle);
let quiet = o.json("issue_create", json!({"repo": hush, "title": "Quiet"}))["uri"]
.as_str()
.unwrap()
.to_string();
let public = owner.records("dev.knotless.reaction").len();
let heart = json!({"repo": hush, "uri": quiet, "content": "heart"});
let reacted = o.json("issue_react", heart.clone());
assert_eq!(reacted["reacted"], true);
assert_eq!(o.json("issue_react", heart)["uri"], reacted["uri"]);
assert_eq!(owner.records("dev.knotless.reaction").len(), public);
let shown = o.json("pull_show", json!({"repo": hush, "uri": quiet}));
assert_eq!(shown["reactions"], json!({"heart": 1}));
let hushed = format!("knotless://{hush}");
let undo = [
"issue",
"react",
hushed.as_str(),
quiet.as_str(),
"heart",
"--undo",
];
assert_eq!(owner.cli(&undo, b""), (0, "taken back".to_string()));
let shown = o.json("pull_show", json!({"repo": hush, "uri": quiet}));
assert!(shown["reactions"].is_null(), "{shown}");
}
}
#[test]
fn agents_publish_releases() {
let s = logged_in();
let c = Client::new(&s);
let repo = format!("{}/shipped", s.account.handle);
c.ok(
"repo_commit",
json!({"repo": repo, "branch": "main", "message": "start", "changes": [{"path": "a", "content": "a"}]}),
);
let big = vec![b'x'; (1 << 20) + 1];
use base64::Engine;
let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
let published = c.json(
"release_publish",
json!({"repo": repo, "package": "tool", "version": "1.0.0", "notes": "First.", "commit": "c".repeat(40), "files": [
{"name": "README", "content": "read me\n"},
{"name": "tool.bin", "base64": b64(&[0, 1, 2, 255])},
{"name": "big.txt", "base64": b64(&big)},
]}),
);
assert!(
published["uri"]
.as_str()
.unwrap()
.contains("/dev.knotless.release/")
);
c.ok(
"release_publish",
json!({"repo": repo, "package": "tool", "version": "1.1.0-rc.1", "files": [{"name": "README", "content": "next\n"}]}),
);
c.ok(
"release_publish",
json!({"repo": repo, "package": "other", "version": "0.1.0", "files": [{"name": "x", "content": "x"}]}),
);
for (files, says) in [
(
json!([{"name": "a", "content": "x", "base64": "eA=="}]),
"exactly one",
),
(json!([{"name": "a"}]), "exactly one"),
(json!([{"name": "a", "base64": "!!"}]), "Invalid"),
] {
let (text, err) = c.call(
"release_publish",
json!({"repo": repo, "package": "tool", "version": "2.0.0", "files": files}),
);
assert!(err && text.contains(says), "{text}");
}
let (text, err) = c.call(
"release_publish",
json!({"repo": repo, "package": "tool", "version": "1.0.0", "files": [{"name": "a", "content": "x"}]}),
);
assert!(err && text.contains("published already"), "{text}");
// A yanked version is marked and is not the latest, until undone.
let tool = json!({"repo": repo, "package": "tool", "version": "1.0.0"});
let uri = c.json("release_yank", tool.clone())["uri"].clone();
assert!(uri.as_str().unwrap().contains("/dev.knotless.release/"));
let yanked = c.json("release_list", json!({"repo": repo, "package": "tool"}));
let stable = yanked
.as_array()
.unwrap()
.iter()
.find(|r| r["version"] == "1.0.0")
.unwrap();
assert_eq!(
(&stable["yanked"], &stable["latest"]),
(&json!(true), &json!(false))
);
let mut undone = tool;
undone["undo"] = json!(true);
assert_eq!(c.json("release_yank", undone)["uri"], uri);
// By package, highest first; the pre-release is never the latest.
let all = c.json("release_list", json!({"repo": repo}));
let listed: Vec<(&str, &str, bool)> = all
.as_array()
.unwrap()
.iter()
.map(|r| {
let (p, v) = (
r["package"].as_str().unwrap(),
r["version"].as_str().unwrap(),
);
(p, v, r["latest"].as_bool().unwrap())
})
.collect();
assert_eq!(
listed,
[
("other", "0.1.0", true),
("tool", "1.1.0-rc.1", false),
("tool", "1.0.0", true)
]
);
let tool = c.json("release_list", json!({"repo": repo, "package": "tool"}));
assert_eq!(tool.as_array().unwrap().len(), 2);
let first = &tool[1];
assert_eq!(
(first["notes"].as_str(), first["commit"].as_str()),
(Some("First."), Some("c".repeat(40).as_str()))
);
assert_eq!(
first["files"][1],
json!({"name": "tool.bin", "size": 4, "sha256": knotless::release::sha256(&[0, 1, 2, 255])})
);
// A file of the latest, or of a version named, as text or base64.
let read = |args: Value| c.json("release_read", args);
let latest = read(json!({"repo": repo, "package": "tool", "file": "README"}));
assert_eq!(latest, json!({"version": "1.0.0", "content": "read me\n"}));
let rc =
read(json!({"repo": repo, "package": "tool", "version": "1.1.0-rc.1", "file": "README"}));
assert_eq!(rc["content"], "next\n");
let bin = read(json!({"repo": repo, "package": "tool", "file": "tool.bin"}));
assert_eq!(bin["base64"], b64(&[0, 1, 2, 255]));
for (args, says) in [
(
json!({"package": "tool", "version": "9.9.9", "file": "README"}),
"has published no tool 9.9.9",
),
(
json!({"package": "none", "file": "README"}),
"has published no none latest",
),
(
json!({"package": "tool", "file": "missing"}),
"has no file missing",
),
(
json!({"package": "tool", "file": "big.txt"}),
"knotless release download",
),
] {
let mut args = args;
args["repo"] = json!(repo);
let (text, err) = c.call("release_read", args);
assert!(err && text.contains(says), "{text}");
}
}
/// An account that invited no one is no organization; inviting takes a
/// gateway to act through, as the command line's does, and leaving takes
/// back the join record whatever the gateway does.
#[test]
fn organizations_over_mcp() {
// Who is in an organization is read from the appview.
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt, "");
let mut s = logged_in();
s.set("KNOTLESS_APPVIEW", &base);
let other = account();
let c = Client::new(&s);
let me = s.account.handle.clone();
let none = |did: &str, name: &str, inside: bool, changed: Value| json!({"org": did, "name": name, "organization": false, "inside": inside, "entries": [], "changed": changed});
let mine = |changed| none(&s.account.did, &me, true, changed);
assert_eq!(c.json("org_members", json!({})), mine(Value::Null));
let removed = json!({"org": me, "who": other.handle, "organization": false});
assert_eq!(
c.json("org_remove", json!({"handle": other.handle})),
mine(removed)
);
let deleted = json!({"removed": []});
assert_eq!(c.json("org_delete", json!({})), mine(deleted.clone()));
assert_eq!(c.json("org_delete", json!({"yes": true})), mine(deleted));
let theirs = none(&other.did, &other.handle, false, Value::Null);
assert_eq!(c.json("org_members", json!({"org": other.handle})), theirs);
let public = c.json("org_members", json!({"org": other.handle, "public": true}));
assert_eq!(public, theirs);
let (text, err) = c.call("org_add", json!({"handle": other.handle, "admin": true}));
assert!(
err && text.ends_with("delegates its writes with the account_delegate tool"),
"{text}"
);
let (text, err) = c.call("org_join", json!({"org": other.handle}));
assert!(
err && text.contains("exactly one of public or hidden"),
"{text}"
);
let (text, err) = c.call("org_join", json!({"org": other.handle, "public": true}));
assert!(err && text.contains("is not an organization"), "{text}");
let (text, err) = c.call("org_join", json!({"org": other.handle, "hidden": true}));
assert!(err, "{text}");
let (text, err) = c.call("org_leave", json!({"org": other.handle}));
assert!(err && text.contains("you are not a member of"), "{text}");
// Memberships in public: none yet.
assert_eq!(c.json("org_list", json!({})), json!({"organizations": []}));
// An account that keeps an organization record is one, so ending it
// asks first.
s.put_record(
"dev.knotless.org",
"self",
json!({"$type": "dev.knotless.org", "createdAt": "2026-10-28T00:00:00Z"}),
);
wait_until(|| c.json("org_members", json!({}))["organization"] == true);
let (text, err) = c.call("org_delete", json!({}));
assert!(err && text.contains("call again with yes"), "{text}");
let ended = c.json("org_delete", json!({"yes": true}));
assert_eq!(ended["changed"], json!({"removed": []}), "{ended}");
// A join record left behind goes, though no gateway takes the rest.
let joined = json!({"$type": "dev.knotless.orgAcceptance", "subject": other.did, "createdAt": "2026-10-28T00:00:00Z"});
s.put_record("dev.knotless.orgAcceptance", &other.did, joined);
let left = c.json("org_leave", json!({"org": other.handle}));
assert_eq!(left["changed"]["left"], json!(other.handle));
let warning = left["changed"]["warning"].as_str().unwrap_or_default();
assert!(warning.contains("acts through no gateway"), "{left}");
assert!(s.records("dev.knotless.orgAcceptance").is_empty());
}
/// Pulls, issues, a pull's diff and files are resources as well as tools,
/// by URIs that mirror their pages, each read as its tool answers.
#[test]
fn agents_read_resources() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt, "");
let mut owner = logged_in();
owner.set("KNOTLESS_APPVIEW", &base);
let c = Client::new(&owner);
let info = c.peer.peer_info().unwrap();
assert!(info.capabilities.resources.is_some(), "{info:?}");
assert!(info.capabilities.tools.is_some(), "{info:?}");
let templates = c.rt.block_on(c.peer.list_all_resource_templates()).unwrap();
let listed: Vec<(&str, &str, Option<&str>)> = templates
.iter()
.map(|t| {
assert!(t.title.is_some() && t.description.is_some(), "{t:?}");
(
t.name.as_str(),
t.uri_template.as_str(),
t.mime_type.as_deref(),
)
})
.collect();
let (json, text) = (Some("application/json"), Some("text/plain"));
assert_eq!(
listed,
[
("pulls", "knotless://{owner}/{repo}/pulls", json),
("issues", "knotless://{owner}/{repo}/issues", json),
("pull", "knotless://{owner}/{repo}/pull/{did}/{rkey}", json),
(
"pull_diff",
"knotless://{owner}/{repo}/pull/{did}/{rkey}/diff",
text
),
(
"issue",
"knotless://{owner}/{repo}/issue/{did}/{rkey}",
json
),
("file", "knotless://{owner}/{repo}/file/{rev}/{+path}", text),
]
);
// Only templates: there are too many pulls and files to list.
assert!(
c.rt.block_on(c.peer.list_all_resources())
.unwrap()
.is_empty()
);
let repo = c_repo(&c.json("repo_create", json!({"name": "resourced"})));
c.json(
"repo_commit",
json!({"repo": repo, "branch": "main", "message": "start", "changes": [
{"path": "README.md", "content": "hello\n"},
{"path": "logo.bin", "base64": "AAEC/w=="},
]}),
);
c.json("branch_create", json!({"repo": repo, "name": "topic/more"}));
c.json(
"repo_commit",
json!({"repo": repo, "branch": "topic/more", "message": "more", "changes": [
{"path": "docs/a b.md", "content": "on the branch\n"},
]}),
);
let opened = json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "topic/more", "title": "More", "body": "more"});
let pull = c.json("pull_create", opened)["uri"]
.as_str()
.unwrap()
.to_string();
let issue = c.json("issue_create", json!({"repo": repo, "title": "Less"}))["uri"]
.as_str()
.unwrap()
.to_string();
c.json(
"comment_add",
json!({"repo": repo, "uri": pull, "body": "looks right"}),
);
// The lists from the appview, once it has the pull, its comment and
// the issue, so a tool and its resource read the same.
let all = |tool: &str| c.json(tool, json!({"repo": repo}));
wait_until(|| {
let shown = c.json("pull_show", json!({"repo": repo, "uri": pull}));
let pulls = all("pull_list");
pulls[0]["comments"] == 1
&& all("issue_list").as_array().unwrap().len() == 1
&& shown["comments"]
.as_array()
.is_some_and(|said| said.len() == 1)
});
// Each as its tool answers, JSON or text.
let page = |uri: &str| {
let parts: Vec<&str> = uri.strip_prefix("at://").unwrap().split('/').collect();
format!("{}/{}", parts[0], parts[2])
};
let url = format!("knotless://{repo}");
let as_tool = |uri: String, tool: &str, args: Value| {
let read = c.read(&uri).unwrap();
assert_eq!(read["uri"], uri.as_str());
let said = c.ok(tool, args);
match read["mimeType"].as_str().unwrap() {
"application/json" => {
let got: Value = serde_json::from_str(read["text"].as_str().unwrap()).unwrap();
assert_eq!(got, serde_json::from_str::<Value>(&said).unwrap(), "{uri}");
}
mime => {
assert_eq!(mime, "text/plain", "{uri}");
assert_eq!(read["text"], said.as_str(), "{uri}");
}
}
read
};
let subject = json!({"repo": repo, "uri": pull});
as_tool(format!("{url}/pulls"), "pull_list", json!({"repo": repo}));
as_tool(format!("{url}/issues"), "issue_list", json!({"repo": repo}));
let shown = as_tool(
format!("{url}/pull/{}", page(&pull)),
"pull_show",
subject.clone(),
);
assert!(shown["text"].as_str().unwrap().contains("looks right"));
let diff = as_tool(
format!("{url}/pull/{}/diff", page(&pull)),
"pull_diff",
subject,
);
assert!(
diff["text"]
.as_str()
.unwrap()
.contains(" 1 | +on the branch")
);
as_tool(
format!("{url}/issue/{}", page(&issue)),
"issue_show",
json!({"repo": repo, "uri": issue}),
);
// A branch's `/` in the revision, and a space in the path, escaped as a
// template's expansion escapes them; the owner by DID, its `:` too.
let did = owner.account.did.replace(':', "%3A");
let read = as_tool(
format!("knotless://{did}/resourced/file/topic%2Fmore/docs/a%20b.md"),
"repo_read",
json!({"repo": repo, "rev": "topic/more", "path": "docs/a b.md"}),
);
assert_eq!(read["text"], "on the branch\n");
// A binary file as a blob.
let logo = c.read(&format!("{url}/file/main/logo.bin")).unwrap();
assert_eq!(logo["blob"], "AAEC/w==");
assert_eq!(logo["mimeType"], "application/octet-stream");
// A read that fails says what the tool would.
let gone = c.read(&format!("{url}/file/main/gone.md")).unwrap_err();
let (said, err) = c.call("repo_read", json!({"repo": repo, "path": "gone.md"}));
assert!(err);
assert_eq!(gone.message, said);
// A URI no template makes names those that are.
for bad in [
format!("https://knotless.dev/{repo}/pulls"),
url.clone(),
format!("{url}/pulls/"),
format!("{url}/pull/{}/patch", page(&pull)),
format!("{url}/issue/{}/diff", page(&issue)),
format!("{url}/file/main"),
format!("{url}/file/main//a"),
format!("{url}/file/%FF/README.md"),
] {
let err = c.read(&bad).unwrap_err();
assert_eq!(
err.code,
rmcp::model::ErrorCode::RESOURCE_NOT_FOUND,
"{bad}"
);
assert!(
err.message.contains(&bad)
&& err
.message
.contains("knotless://{owner}/{repo}/file/{rev}/{+path}"),
"{err:?}"
);
}
}