//! 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, } 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 { 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 { 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 {{ 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 = "\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 = "\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 ."}), ); } #[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 { 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 { 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 = 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 { 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![the page](http"), "{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 = (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::(&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:?}" ); } }