From fefab20091d51c6d0cf778de0271a0ebbd6b8697 Mon Sep 17 00:00:00 2001 From: Lewis Date: Fri, 2 Oct 2026 10:32:30 +0300 Subject: [PATCH] knot-xrpc: more work on ticket and org.tangled records Lewis: May this revision serve well! --- knot2/crates/knot-xrpc/src/tests.rs | 2718 ++++++++++----------------- 1 file changed, 974 insertions(+), 1744 deletions(-) diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs index df4e14ecb..ce3883c9a 100644 --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -19,7 +19,7 @@ use knot_atproto::Atproto; use knot_cobs::{CollaboratorsChange, CollaboratorsCob, Grant, MembersChange, MembersCob}; use knot_git::{Layout, Repo}; use knot_index::{Index, Resolved}; -use knot_record::issue::IssueState; +use knot_record::ticket::TicketState; use knot_runtime::{ FakeHttp, HttpRequest, HttpResponse, HttpTransport, K256Signer, ManualClock, NetworkError, OsEntropy, SeededEntropy, Signer, UnixMicros, @@ -390,7 +390,6 @@ fn world_responder( pubkeys: HashMap>, repo_docs: Arc>>, pds_records: Arc>>, - pds_bodies: Arc>>, reads: Arc, ) -> Responder { let doc_url = format!("https://{KNOT_HOST}"); @@ -410,13 +409,6 @@ fn world_responder( .find(|(key, _)| key == "rkey") .map(|(_, value)| value.into_owned()) .unwrap_or_default(); - if let Some(body) = pds_bodies.lock().get(&rkey).cloned() { - return Ok(HttpResponse { - status: StatusCode::OK, - headers: http::HeaderMap::new(), - body, - }); - } let answer = pds_records.lock().get(&rkey).copied(); return Ok(HttpResponse { status: answer.unwrap_or(StatusCode::BAD_REQUEST), @@ -464,7 +456,6 @@ struct World { passerby: Actor, repo_docs: Arc>>, pds_records: Arc>>, - pds_bodies: Arc>>, clock: Arc, reads: Arc, } @@ -605,13 +596,11 @@ impl World { ) }) .collect(); - let pds_bodies: Arc>> = Arc::new(Mutex::new(HashMap::new())); let reads = Arc::new(AtomicUsize::new(0)); let responder = world_responder( pubkeys, Arc::clone(&repo_docs), Arc::clone(&pds_records), - Arc::clone(&pds_bodies), Arc::clone(&reads), ); let (state, clock) = state_from( @@ -640,7 +629,6 @@ impl World { passerby, repo_docs, pds_records, - pds_bodies, clock, reads, } @@ -663,20 +651,6 @@ impl World { .insert(host.to_string(), multikey.to_string()); } - fn publish_remote_def(&self, rkey: &str, value: serde_json::Value) { - self.pds_bodies.lock().insert( - rkey.to_string(), - Bytes::from( - serde_json::to_vec(&json!({ - "uri": format!("at://did/sh.tangled.label.definition/{rkey}"), - "cid": "bafyreihdwdhef9h1o6xtmxpgpkwp0shftvtjhu2suxcbhelperphpriljku", - "value": value - })) - .unwrap(), - ), - ); - } - fn advance(&self, secs: u64) { self.clock.advance(std::time::Duration::from_secs(secs)); } @@ -1137,10 +1111,8 @@ async fn a_second_write_inside_one_account_s_burst_answers_429_named_by_bucket() const CREATE_RECORD: &str = "com.atproto.repo.createRecord"; const PUT_RECORD: &str = "com.atproto.repo.putRecord"; const DELETE_RECORD: &str = "com.atproto.repo.deleteRecord"; -const ISSUE: &str = "sh.tangled.repo.issue"; -const ISSUE_STATE: &str = "sh.tangled.repo.issue.state"; -const OPEN: &str = "sh.tangled.repo.issue.state.open"; -const CLOSED: &str = "sh.tangled.repo.issue.state.closed"; +const TICKET: &str = "org.tangled.track.ticket"; +const CLOSED: &str = "closed"; async fn on_repo( world: &World, @@ -1183,9 +1155,9 @@ fn retype(mut record: serde_json::Value, kind: &str) -> serde_json::Value { record } -fn issue_record(repo: &RepoDid, title: &str, text: &str) -> serde_json::Value { +fn ticket_record(repo: &RepoDid, title: &str, text: &str) -> serde_json::Value { json!({ - "$type": ISSUE, + "$type": TICKET, "repo": repo, "title": title, "body": text, @@ -1263,83 +1235,165 @@ async fn delete_as( .await } -async fn open_issue( - world: &World, - actor: &Actor, - repo: &RepoDid, - title: &str, - text: &str, -) -> (StatusCode, serde_json::Value) { - create_as( - world, - actor, - repo, - ISSUE, - None, - issue_record(repo, title, text), - ) - .await +struct Ticket<'a> { + world: &'a World, + repo: &'a RepoDid, } -async fn edit_issue( - world: &World, - actor: &Actor, - repo: &RepoDid, - uri: &AtUri, - from: &crate::out::CidString, - title: &str, -) -> (StatusCode, serde_json::Value) { - put_as( - world, - actor, - repo, - ISSUE, - rkey_of(uri), - json!({ "swapRecord": from }), - issue_record(repo, title, "kelp forest"), - ) - .await +impl<'a> Ticket<'a> { + fn on(world: &'a World, repo: &'a RepoDid) -> Self { + Self { world, repo } + } + + async fn open( + &self, + actor: &Actor, + title: &str, + text: &str, + ) -> (StatusCode, serde_json::Value) { + create_as( + self.world, + actor, + self.repo, + TICKET, + None, + ticket_record(self.repo, title, text), + ) + .await + } + + async fn opened(&self, actor: &Actor, title: &str) -> (AtUri, crate::out::CidString) { + let written = ok(self.open(actor, title, "a forest").await); + (uri_of(&written), cid_of(&written)) + } + + async fn create( + &self, + actor: &Actor, + record: serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + create_as(self.world, actor, self.repo, TICKET, None, record).await + } + + async fn refused_create(&self, record: serde_json::Value) -> serde_json::Value { + failed( + self.create(&self.world.stranger, record).await, + StatusCode::BAD_REQUEST, + ) + } + + async fn put( + &self, + actor: &Actor, + uri: &AtUri, + from: &crate::out::CidString, + record: serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + put_as( + self.world, + actor, + self.repo, + TICKET, + rkey_of(uri), + json!({ "swapRecord": from }), + record, + ) + .await + } + + async fn edit( + &self, + actor: &Actor, + uri: &AtUri, + from: &crate::out::CidString, + title: &str, + ) -> (StatusCode, serde_json::Value) { + self.put( + actor, + uri, + from, + ticket_record(self.repo, title, "kelp forest"), + ) + .await + } + + async fn set_state( + &self, + actor: &Actor, + uri: &AtUri, + from: &crate::out::CidString, + state: TicketState, + ) -> (StatusCode, serde_json::Value) { + let mut record = ticket_record(self.repo, "kelp", "a forest"); + record["state"] = json!(state); + self.put(actor, uri, from, record).await + } + + async fn erase( + &self, + actor: &Actor, + uri: &AtUri, + from: &crate::out::CidString, + ) -> (StatusCode, serde_json::Value) { + delete_as( + self.world, + actor, + self.repo, + TICKET, + rkey_of(uri), + json!({ "swapRecord": from }), + ) + .await + } } -async fn transition( - world: &World, - actor: &Actor, - repo: &RepoDid, - uri: &AtUri, - state: IssueState, -) -> (StatusCode, serde_json::Value) { - create_as( - world, - actor, - repo, - ISSUE_STATE, - None, - json!({ - "$type": ISSUE_STATE, - "issue": uri, - "state": state, - "createdAt": "2026-09-11T00:00:00Z" - }), - ) - .await +struct TicketDraft { + record: serde_json::Value, } -async fn delete_issue( - world: &World, - actor: &Actor, - repo: &RepoDid, - uri: &AtUri, - from: &crate::out::CidString, -) -> (StatusCode, serde_json::Value) { - delete_as( - world, - actor, - repo, - ISSUE, - rkey_of(uri), - json!({ "swapRecord": from }), - ) - .await +impl TicketDraft { + fn fresh(repo: &RepoDid) -> Self { + Self { + record: ticket_record(repo, "kelp", "a forest"), + } + } + + fn labeled(mut self) -> Self { + self.record["labels"] = + json!({ "$type": "com.atproto.label.defs#selfLabels", "values": [] }); + self + } + + fn misdeclared_title(mut self) -> Self { + self.record["title"] = json!({ "$type": "org.tangled.markup.markdown", "text": "kelp" }); + self + } + + fn flooded_body(mut self) -> Self { + self.record["body"] = json!({ + "$type": "org.tangled.markup.markdown", + "text": "k".repeat(knot_record::ticket::MAX_BODY_BYTES + 1), + }); + self + } + + fn dense_body(mut self) -> Self { + self.record["body"] = json!({ + "$type": "org.tangled.markup.markdown", + "text": "e\u{301}".repeat(knot_record::ticket::MAX_BODY_GRAPHEMES + 1), + }); + self + } + + fn citing(mut self, key: &str) -> Self { + self.record["tangledLabels"] = json!([{ "key": key }]); + self + } + + fn on(mut self, branch: &str) -> Self { + self.record["targetBranch"] = json!(branch); + self + } } fn rkey_of(uri: &AtUri) -> String { @@ -1481,23 +1535,13 @@ fn message_contains(body: &serde_json::Value, fragments: &[&str]) { ); } -async fn issue_world() -> (World, RepoDid) { +async fn ticket_world() -> (World, RepoDid) { let world = World::new(); world.grandfather(MEMBER_HOST); let repo = create_repo_helper(&world, "anemone").await; (world, repo) } -async fn opened( - world: &World, - actor: &Actor, - repo: &RepoDid, - title: &str, -) -> (AtUri, crate::out::CidString) { - let written = ok(open_issue(world, actor, repo, title, "a forest").await); - (uri_of(&written), cid_of(&written)) -} - fn uri_of(body: &serde_json::Value) -> AtUri { AtUri::new_owned(body["uri"].as_str().unwrap()).unwrap() } @@ -1507,14 +1551,15 @@ fn cid_of(body: &serde_json::Value) -> crate::out::CidString { } #[tokio::test] -async fn stranger_opens_issues_until_policy_closes_and_reads_collaborators_remedy() { - let (world, repo) = issue_world().await; +async fn stranger_opens_tickets_until_policy_closes_and_reads_collaborators_remedy() { + let (world, repo) = ticket_world().await; + let ticket = Ticket::on(&world, &repo); - let written = ok(open_issue(&world, &world.stranger, &repo, "kelp", "a forest").await); + let written = ok(ticket.open(&world.stranger, "kelp", "a forest").await); let uri = uri_of(&written); assert!( uri.as_str() - .starts_with(&format!("at://{}/{ISSUE}/", repo.as_str())) + .starts_with(&format!("at://{}/{TICKET}/", repo.as_str())) ); assert!(written["cid"].as_str().unwrap().starts_with("bafyrei")); assert!( @@ -1532,17 +1577,16 @@ async fn stranger_opens_issues_until_policy_closes_and_reads_collaborators_remed record["cid"], written["cid"], "getRecord answers the version createRecord wrote" ); - assert_eq!(record["value"]["$type"], ISSUE); - assert_eq!(record["value"]["repo"], repo.as_str()); - assert_eq!(record["value"]["title"], "kelp"); - assert_eq!(record["value"]["body"], "a forest"); + assert_eq!(record["value"]["$type"], TICKET); + assert_eq!(record["value"]["title"]["text"], "kelp"); + assert_eq!(record["value"]["body"]["text"], "a forest"); assert_eq!( record["value"]["x-tngl-editor"], world.stranger.did.as_str(), "the editor ought to be the authenticated writer, never the repo DID" ); assert_eq!( - records_in(&world, &repo, ISSUE).await, + records_in(&world, &repo, TICKET).await, vec![uri.as_str().to_owned()] ); @@ -1557,29 +1601,31 @@ async fn stranger_opens_issues_until_policy_closes_and_reads_collaborators_remed StatusCode::OK ); let rejected = failed( - open_issue(&world, &world.stranger, &repo, "urchin", "").await, + ticket.open(&world.stranger, "urchin", "").await, StatusCode::FORBIDDEN, ); message_contains( &rejected, &["stranger", "collaborators", "add you as a collaborator"], ); - ok(open_issue(&world, &world.member, &repo, "urchin", "").await); + ok(ticket.open(&world.member, "urchin", "").await); } #[tokio::test] async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { - let (world, repo) = issue_world().await; - let (uri, cid) = opened(&world, &world.stranger, &repo, "kelp").await; + let (world, repo) = ticket_world().await; + let (uri, cid) = Ticket::on(&world, &repo) + .opened(&world.stranger, "kelp") + .await; let minted = failed( create_as( &world, &world.stranger, &repo, - ISSUE, + TICKET, Some(rkey_of(&uri)), - issue_record(&repo, "again", ""), + ticket_record(&repo, "again", ""), ) .await, StatusCode::BAD_REQUEST, @@ -1597,7 +1643,7 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { repo, "com.example.gripe", rkey, - retype(issue_record(repo, "no", ""), "com.example.gripe"), + retype(ticket_record(repo, "no", ""), "com.example.gripe"), ) .await, StatusCode::BAD_REQUEST, @@ -1612,9 +1658,9 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { &world, &world.stranger, &repo, - ISSUE, + TICKET, None, - retype(issue_record(&repo, "kelp", ""), "sh.tangled.repo.pull"), + retype(ticket_record(&repo, "kelp", ""), "org.tangled.review.patch"), ) .await, StatusCode::BAD_REQUEST, @@ -1626,7 +1672,7 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { &world, &world.stranger, &repo, - ISSUE, + TICKET, rkey_of(&uri), json!({ "swapCommit": cid }), ) @@ -1640,7 +1686,7 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { &world, &world.stranger, &repo, - ISSUE, + TICKET, rkey_of(&uri), json!({}), ) @@ -1654,13 +1700,13 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { &world, &world.member, &repo, - ISSUE_STATE, + "org.tangled.feed.reaction", rkey_of(&uri), json!({ "swapRecord": cid }), json!({ - "$type": ISSUE_STATE, - "issue": uri, - "state": OPEN, + "$type": "org.tangled.feed.reaction", + "subject": { "uri": uri, "cid": cid }, + "reaction": "❤️", "createdAt": "2026-09-11T00:00:00Z" }), ) @@ -1672,8 +1718,9 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { #[tokio::test] async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { - let (world, repo) = issue_world().await; - let first = ok(open_issue(&world, &world.stranger, &repo, "kelp", "a forest").await); + let (world, repo) = ticket_world().await; + let ticket = Ticket::on(&world, &repo); + let first = ok(ticket.open(&world.stranger, "kelp", "a forest").await); let uri = uri_of(&first); let v1 = cid_of(&first); assert!( @@ -1685,12 +1732,14 @@ async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { "Before the delete, replay off the ring oughtta serve the leaf" ); - let second = ok(edit_issue(&world, &world.stranger, &repo, &uri, &v1, "kelp, again").await); + let second = ok(ticket.edit(&world.stranger, &uri, &v1, "kelp, again").await); let v2 = cid_of(&second); assert_ne!(v1, v2); let stale = failed( - edit_issue(&world, &world.stranger, &repo, &uri, &v1, "kelp, thrice").await, + ticket + .edit(&world.stranger, &uri, &v1, "kelp, thrice") + .await, StatusCode::CONFLICT, ); assert_eq!(stale["error"], "InvalidSwap"); @@ -1701,20 +1750,14 @@ async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { "the 409 ought to return the version that stands" ); failed( - edit_issue( - &world, - &world.passerby, - &repo, - &uri, - &v2, - "kelp, by the passerby", - ) - .await, + ticket + .edit(&world.passerby, &uri, &v2, "kelp, by the passerby") + .await, StatusCode::FORBIDDEN, ); - assert_eq!(records_in(&world, &repo, ISSUE).await.len(), 1); + assert_eq!(records_in(&world, &repo, TICKET).await.len(), 1); - let same = ok(edit_issue(&world, &world.stranger, &repo, &uri, &v2, "kelp, again").await); + let same = ok(ticket.edit(&world.stranger, &uri, &v2, "kelp, again").await); assert_eq!( same["cid"], v2.to_string(), @@ -1722,19 +1765,13 @@ async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { ); assert_eq!(same["commit"], second["commit"]); - let third = ok(edit_issue( - &world, - &world.member, - &repo, - &uri, - &v2, - "kelp, by the owner", - ) - .await); + let third = ok(ticket + .edit(&world.member, &uri, &v2, "kelp, by the owner") + .await); let v3 = cid_of(&third); let record = ok(record_at(&world, &repo, &uri, None).await); assert_eq!(record["cid"], v3.to_string()); - assert_eq!(record["value"]["title"], "kelp, by the owner"); + assert_eq!(record["value"]["title"]["text"], "kelp, by the owner"); assert_eq!( record["value"]["x-tngl-editor"], world.member.did.as_str(), @@ -1760,14 +1797,14 @@ async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { &world, &world.admin, &repo, - ISSUE, + TICKET, rkey_of(&uri), json!({ "swapRecord": "bafyreidoesnotparse" }), ) .await, StatusCode::BAD_REQUEST, ); - let deleted = ok(delete_issue(&world, &world.member, &repo, &uri, &v3).await); + let deleted = ok(ticket.erase(&world.member, &uri, &v3).await); assert!( deleted.get("uri").is_none() && deleted.get("cid").is_none(), "Tombstone answer oughtta be the stock commit alone" @@ -1782,7 +1819,7 @@ async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { let (status, gone) = record_at(&world, &repo, &uri, None).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(gone["error"], "RecordNotFound"); - assert!(records_in(&world, &repo, ISSUE).await.is_empty()); + assert!(records_in(&world, &repo, TICKET).await.is_empty()); assert!( block_served(&world, &repo, &v1).await, "The block tree still keeps the prose git can't delete" @@ -1822,10 +1859,10 @@ async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { ); let again = failed( - delete_issue(&world, &world.member, &repo, &uri, &v1).await, + ticket.erase(&world.member, &uri, &v1).await, StatusCode::NOT_FOUND, ); - assert_eq!(again["message"], format!("issue at {uri} was deleted")); + assert_eq!(again["message"], format!("ticket at {uri} was deleted")); assert!( replayed_blocks(&world).contains(&v1.cid()), "Replay serves the stored frame, whose create commit still includes the leaf" @@ -1892,7 +1929,7 @@ fn tip_rev(world: &World, repo: &RepoDid) -> String { let git = world.layout.open(repo).unwrap(); knot_record::chain::tip(&git) .unwrap() - .expect("issue_world enables the chain") + .expect("ticket_world enables the chain") .rev .to_string() } @@ -1916,7 +1953,7 @@ fn cob_version_bytes( knot_index::Resolved::Ready(Some(object)) => object, other => panic!("the written issue ought to project to an object, got {other:?}"), }; - let type_name = knot_types::TypeName::new(ISSUE).unwrap(); + let type_name = knot_types::TypeName::new(TICKET).unwrap(); let delta = knot_cob::CobStore::new(&git) .changes_of(&type_name, object, knot_cob::HistoryModel::Convergent, None) .unwrap(); @@ -1936,15 +1973,16 @@ fn cob_version_bytes( #[tokio::test] async fn deleted_prose_stays_off_every_presenting_route_and_reachable_from_history() { - let (world, repo) = issue_world().await; + let (world, repo) = ticket_world().await; let before = tip_rev(&world, &repo); - let first = ok(open_issue(&world, &world.stranger, &repo, "kelp", "a forest").await); + let ticket = Ticket::on(&world, &repo); + let first = ok(ticket.open(&world.stranger, "kelp", "a forest").await); let uri = uri_of(&first); let v1 = cid_of(&first); let birth = first["commit"]["rev"].as_str().unwrap().to_owned(); - let second = ok(edit_issue(&world, &world.stranger, &repo, &uri, &v1, "kelp, again").await); + let second = ok(ticket.edit(&world.stranger, &uri, &v1, "kelp, again").await); let v2 = cid_of(&second); - ok(delete_issue(&world, &world.stranger, &repo, &uri, &v2).await); + ok(ticket.erase(&world.stranger, &uri, &v2).await); let gone = failed( record_at(&world, &repo, &uri, None).await, @@ -1960,7 +1998,7 @@ async fn deleted_prose_stays_off_every_presenting_route_and_reachable_from_histo "The deleted version's own CID mustn't resurrect it." ); assert!( - records_in(&world, &repo, ISSUE).await.is_empty(), + records_in(&world, &repo, TICKET).await.is_empty(), "listRecords must drop the row." ); let proof = sync_record_blocks(&world, &repo, &uri).await; @@ -2038,29 +2076,152 @@ async fn deleted_prose_stays_off_every_presenting_route_and_reachable_from_histo } #[tokio::test] -async fn state_transition_belongs_to_author_or_maintainer_and_repeat_answers_409() { - let (world, repo) = issue_world().await; - let (uri, _) = opened(&world, &world.stranger, &repo, "kelp").await; +async fn an_edit_echoing_the_live_state_still_writes() { + let (world, repo) = ticket_world().await; + let ticket = Ticket::on(&world, &repo); + let (uri, v1) = ticket.opened(&world.stranger, "kelp").await; + let (_, _def) = wontfix(&world, &repo).await; + + let mut conformant = ticket_record(&repo, "kelp", "a forest"); + conformant["state"] = json!("open"); + conformant["tangledLabels"] = json!([{ "key": "wontfix" }]); + let edited = ok(ticket + .put(&world.stranger, &uri, &v1, conformant.clone()) + .await); + let v2 = cid_of(&edited); + let served = ok(record_at(&world, &repo, &uri, None).await); + assert_eq!( + served["value"]["tangledLabels"], + json!([{ "key": "wontfix" }]), + "a record echoing the live state can still change the rest" + ); + + let refused = failed( + ticket.put(&world.stranger, &uri, &v2, conformant).await, + StatusCode::CONFLICT, + ); + message_contains(&refused, &["already open"]); + + let mut without_repo = ticket_record(&repo, "urchin", "a shoal"); + without_repo + .as_object_mut() + .expect("a ticket draft is an object") + .remove("repo"); + let bare = ok(ticket.create(&world.stranger, without_repo).await); + let bare_uri = uri_of(&bare); + assert!( + bare_uri + .collection() + .is_some_and(|collection| collection.as_str() == TICKET), + "a draft without the dropped repo field still opens a ticket" + ); +} + +#[tokio::test] +async fn a_served_ticket_round_trips_through_put_record() { + let (world, repo) = ticket_world().await; + let ticket = Ticket::on(&world, &repo); + let (uri, v1) = ticket.opened(&world.stranger, "kelp").await; + let served = ok(record_at(&world, &repo, &uri, None).await); + let echoed = served["value"].clone(); + assert_eq!( + echoed["title"]["$type"], "org.tangled.markup.markdown#inline", + "the served title is the lexicon's inline union" + ); + let refused = failed( + ticket.put(&world.stranger, &uri, &v1, echoed.clone()).await, + StatusCode::CONFLICT, + ); + message_contains(&refused, &["already open"]); + + let mut retitled = echoed; + retitled["title"]["text"] = json!("kelp, again"); + ok(ticket.put(&world.stranger, &uri, &v1, retitled).await); + assert_eq!( + ok(record_at(&world, &repo, &uri, None).await)["value"]["title"]["text"], + serde_json::json!("kelp, again"), + "a ticket echoed back with a new title writes the new title" + ); + + let malformed = vec![ + ( + TicketDraft::fresh(&repo).labeled(), + vec!["self-labels", "leave labels out"], + ), + ( + TicketDraft::fresh(&repo).misdeclared_title(), + vec!["a ticket's title is org.tangled.markup.markdown#inline"], + ), + ( + TicketDraft::fresh(&repo).flooded_body(), + vec!["Ticket body's text is unusable"], + ), + ( + TicketDraft::fresh(&repo).dense_body(), + vec![ + "Ticket body's text is unusable", + "graphemes is over the 65536 grapheme limit", + ], + ), + ]; + for (draft, fragments) in malformed { + message_contains(&ticket.refused_create(draft.record).await, &fragments); + } +} + +#[tokio::test] +async fn state_transition_belongs_to_author_or_maintainer_and_a_repeat_gets_409() { + let (world, repo) = ticket_world().await; + let ticket = Ticket::on(&world, &repo); + let (uri, v1) = ticket.opened(&world.stranger, "kelp").await; failed( - transition(&world, &world.stranger, &repo, &uri, IssueState::Open).await, + ticket + .set_state(&world.stranger, &uri, &v1, TicketState::Open) + .await, StatusCode::CONFLICT, ); - let closed = ok(transition(&world, &world.stranger, &repo, &uri, IssueState::Closed).await); - let state_uri = closed["uri"].as_str().unwrap().to_owned(); - assert!(state_uri.starts_with(&format!("at://{}/{ISSUE_STATE}/", repo.as_str()))); - let state = AtUri::::new_owned(state_uri).unwrap(); - let record = ok(record_at(&world, &repo, &state, None).await); - assert_eq!(record["value"]["$type"], ISSUE_STATE); - assert_eq!(record["value"]["issue"], uri.as_str()); + let closed = ok(ticket + .set_state(&world.stranger, &uri, &v1, TicketState::Closed) + .await); + let v2 = cid_of(&closed); + let record = ok(record_at(&world, &repo, &uri, None).await); assert_eq!(record["value"]["state"], CLOSED); failed( - transition(&world, &world.stranger, &repo, &uri, IssueState::Closed).await, + ticket + .set_state(&world.stranger, &uri, &v2, TicketState::Closed) + .await, StatusCode::CONFLICT, ); + let reopened = ok(ticket + .set_state(&world.admin, &uri, &v2, TicketState::Open) + .await); + let v3 = cid_of(&reopened); + let reauthored = ok(ticket + .set_state(&world.member, &uri, &v3, TicketState::Open) + .await); + assert_ne!( + cid_of(&reauthored), + v3, + "a maintainer re-sending an open ticket's own state re-authors the record rather than \ + refusing the echo" + ); + + let (owners, owners_v1) = ticket.opened(&world.member, "urchin").await; + let rejected = failed( + ticket + .set_state(&world.stranger, &owners, &owners_v1, TicketState::Closed) + .await, + StatusCode::FORBIDDEN, + ); + assert_eq!( + rejected["message"], + "State changes belong to the ticket's author or a maintainer" + ); + assert_eq!( set_policy_as( &world, @@ -2071,47 +2232,40 @@ async fn state_transition_belongs_to_author_or_maintainer_and_repeat_answers_409 .await, StatusCode::OK ); - ok(transition(&world, &world.admin, &repo, &uri, IssueState::Open).await); - failed( - transition(&world, &world.member, &repo, &uri, IssueState::Open).await, - StatusCode::CONFLICT, - ); - assert_eq!(records_in(&world, &repo, ISSUE_STATE).await.len(), 2); - let shut_out = failed( - transition(&world, &world.stranger, &repo, &uri, IssueState::Closed).await, - StatusCode::FORBIDDEN, - ); - assert!( - shut_out["message"] - .as_str() - .unwrap() - .ends_with("or to open the contribution policy to members or anyone, thanks."), - "{shut_out}" - ); + let closed = ok(ticket + .set_state( + &world.member, + &uri, + &cid_of(&reauthored), + TicketState::Closed, + ) + .await); assert_eq!( - set_policy_as(&world, &world.member, &repo, ContributionPolicy::Anyone).await, - StatusCode::OK + ok(record_at(&world, &repo, &uri, None).await)["value"]["state"], + CLOSED, + "a maintainer's state write lands under a policy the owner closed" ); - - let (owners, _) = opened(&world, &world.member, &repo, "urchin").await; - let rejected = failed( - transition(&world, &world.stranger, &repo, &owners, IssueState::Closed).await, + let shut = failed( + ticket + .set_state(&world.stranger, &uri, &cid_of(&closed), TicketState::Open) + .await, StatusCode::FORBIDDEN, ); - assert_eq!( - rejected["message"], - "State changes belong to the issue's author or a maintainer" + message_contains( + &shut, + &["stranger", "collaborators", "add you as a collaborator"], ); } #[tokio::test] -async fn racing_creates_take_two_numbers_and_racing_closes_leave_one_transition() { - let (world, repo) = issue_world().await; - let (kelp, _) = opened(&world, &world.stranger, &repo, "kelp").await; +async fn racing_creates_take_two_numbers_and_racing_closes_settle_one_state() { + let (world, repo) = ticket_world().await; + let ticket = Ticket::on(&world, &repo); + let (kelp, kelp_v1) = ticket.opened(&world.stranger, "kelp").await; let (urchin, whelk) = tokio::join!( - open_issue(&world, &world.stranger, &repo, "urchin", ""), - open_issue(&world, &world.member, &repo, "whelk", ""), + ticket.open(&world.stranger, "urchin", ""), + ticket.open(&world.member, "whelk", ""), ); assert_eq!( [urchin.0, whelk.0], @@ -2124,16 +2278,15 @@ async fn racing_creates_take_two_numbers_and_racing_closes_leave_one_transition( world .state .index - .next_subject_number(&repo, &knot_record::issue::issue_collection()), + .next_subject_number(&repo, &knot_record::ticket::ticket_collection()), Resolved::Ready(knot_types::SubjectNumber::new(4)), "#2 and #3 both go because each create must read the number under the lock the other one \ bumped" ); - let uri = kelp; let (left, right) = tokio::join!( - transition(&world, &world.stranger, &repo, &uri, IssueState::Closed), - transition(&world, &world.member, &repo, &uri, IssueState::Closed), + ticket.set_state(&world.stranger, &kelp, &kelp_v1, TicketState::Closed), + ticket.set_state(&world.member, &kelp, &kelp_v1, TicketState::Closed), ); let mut statuses = [left.0, right.0]; statuses.sort(); @@ -2144,22 +2297,28 @@ async fn racing_creates_take_two_numbers_and_racing_closes_leave_one_transition( left.1, right.1 ); - assert_eq!(records_in(&world, &repo, ISSUE_STATE).await.len(), 1); + let settled = ok(record_at(&world, &repo, &kelp, None).await); + assert_eq!( + settled["value"]["state"], CLOSED, + "the close that won the race is the state that stands" + ); } -fn issue_body_bytes(world: &World, actor: &Actor, repo: &RepoDid, title: &str, text: &str) -> u64 { +fn ticket_body_bytes(world: &World, actor: &Actor, title: &str, text: &str) -> u64 { let prose = knot_record::Prose::new( - knot_record::issue::IssueTitle::new(title).unwrap(), - Some(knot_record::issue::MarkdownBody::new(text).unwrap()), + knot_record::ticket::TicketTitle::new(title).unwrap(), + Some(knot_record::ticket::MarkdownBody::new(text).unwrap()), None, None, ) .unwrap(); - let record = knot_record::issue::IssueRecord::new( - repo.clone(), + let record = knot_record::ticket::TicketRecord::new( prose, + knot_record::ticket::TicketState::Open, None, - world.state.now(), + None, + None, + world.state.now(), actor.did.clone(), ) .unwrap(); @@ -2170,22 +2329,23 @@ fn issue_body_bytes(world: &World, actor: &Actor, repo: &RepoDid, title: &str, t async fn social_budget_429s_over_serving_bytes_and_edits_or_deletes_free_them() { let probe = World::new(); probe.grandfather(MEMBER_HOST); - let repo = create_repo_helper(&probe, "anemone").await; - let opened_bytes = issue_body_bytes(&probe, &probe.member, &repo, "kelp", "a forest"); - let edited_bytes = issue_body_bytes(&probe, &probe.member, &repo, "kelp", "kelp forest"); + create_repo_helper(&probe, "anemone").await; + let opened_bytes = ticket_body_bytes(&probe, &probe.member, "kelp", "a forest"); + let edited_bytes = ticket_body_bytes(&probe, &probe.member, "kelp", "kelp forest"); assert!(opened_bytes < edited_bytes); let world = World::with_social_budget(knot_resource::SocialBudgetBytes::new(edited_bytes)); world.grandfather(MEMBER_HOST); let repo = create_repo_helper(&world, "anemone").await; - let (uri, v1) = opened(&world, &world.member, &repo, "kelp").await; - let edited = ok(edit_issue(&world, &world.member, &repo, &uri, &v1, "kelp").await); + let ticket = Ticket::on(&world, &repo); + let (uri, v1) = ticket.opened(&world.member, "kelp").await; + let edited = ok(ticket.edit(&world.member, &uri, &v1, "kelp").await); let v2 = cid_of(&edited); assert_ne!(v1, v2, "a different body mints a different version"); let rejected = failed( - open_issue(&world, &world.member, &repo, "kelp", "a forest").await, + ticket.open(&world.member, "kelp", "a forest").await, StatusCode::TOO_MANY_REQUESTS, ); assert_eq!(rejected["error"], "RateLimitExceeded"); @@ -2201,13 +2361,13 @@ async fn social_budget_429s_over_serving_bytes_and_edits_or_deletes_free_them() "The 429 oughtta explain how to free the bytes: {rejected}" ); - ok(delete_issue(&world, &world.member, &repo, &uri, &v2).await); - let reopened = ok(open_issue(&world, &world.member, &repo, "kelp", "a forest").await); + ok(ticket.erase(&world.member, &uri, &v2).await); + let reopened = ok(ticket.open(&world.member, "kelp", "a forest").await); assert!( reopened["uri"] .as_str() .unwrap() - .contains("/sh.tangled.repo.issue/"), + .contains("/org.tangled.track.ticket/"), "Erasure frees the bytes of the version it retracts" ); } @@ -2402,7 +2562,7 @@ async fn redeeming_an_invite_link_admits_the_token_issuer_and_spends_the_code() assert_eq!( redeem(&world, &world.passerby, &code).await, StatusCode::BAD_REQUEST, - "a spent code must not admit a second account" + "a spent code mustn't admit a second account" ); assert_eq!( world.state.index.effective_member(&account(PASSERBY_HOST)), @@ -5778,13 +5938,13 @@ async fn sweep_gives_up_on_a_permanently_rejected_repository_after_one_post() { assert!(memory.is_given_up(repo.did.as_str())); } -const COMMENT: &str = "sh.tangled.feed.comment"; +const COMMENT: &str = "org.tangled.feed.comment"; const LEGACY_COMMENT: &str = "sh.tangled.repo.issue.comment"; -const MARKUP: &str = "sh.tangled.markup.markdown"; +const MARKUP: &str = "org.tangled.markup.markdown"; -const REACTION: &str = "sh.tangled.feed.reaction"; +const REACTION: &str = "org.tangled.feed.reaction"; fn comment_record( subject: &AtUri, @@ -5821,6 +5981,15 @@ impl<'a> Contribution<'a> { Self { world, repo } } + async fn opened(&self, actor: &Actor, title: &str) -> Opened<'_> { + let (uri, cid) = Ticket::on(self.world, self.repo).opened(actor, title).await; + Opened { + contribution: self, + uri, + cid, + } + } + async fn comment( &self, actor: &Actor, @@ -5892,6 +6061,7 @@ impl<'a> Contribution<'a> { &self, actor: &Actor, subject: &AtUri, + subject_cid: &crate::out::CidString, kind: &str, ) -> (StatusCode, serde_json::Value) { create_as( @@ -5902,7 +6072,7 @@ impl<'a> Contribution<'a> { None, json!({ "$type": REACTION, - "subject": subject, + "subject": { "uri": subject, "cid": subject_cid }, "reaction": kind, "createdAt": "2026-09-11T00:00:00Z" }), @@ -5929,15 +6099,57 @@ impl<'a> Contribution<'a> { } } +struct Opened<'a> { + contribution: &'a Contribution<'a>, + uri: AtUri, + cid: crate::out::CidString, +} + +impl<'a> Opened<'a> { + async fn comment(&self, actor: &Actor, text: &str) -> (StatusCode, serde_json::Value) { + self.contribution + .comment(actor, &self.uri, &self.cid, text) + .await + } + + async fn replying( + &self, + actor: &Actor, + reply: &AtUri, + reply_cid: &crate::out::CidString, + text: &str, + ) -> (StatusCode, serde_json::Value) { + self.contribution + .replying(actor, &self.uri, &self.cid, reply, reply_cid, text) + .await + } + + async fn react(&self, actor: &Actor, kind: &str) -> (StatusCode, serde_json::Value) { + self.contribution + .react(actor, &self.uri, &self.cid, kind) + .await + } + + async fn edit( + &self, + actor: &Actor, + uri: &AtUri, + from: &crate::out::CidString, + text: &str, + ) -> (StatusCode, serde_json::Value) { + self.contribution + .edit(actor, uri, from, &self.uri, &self.cid, text) + .await + } +} + #[tokio::test] async fn comment_opens_edits_and_deletes_along_its_version_chain() { - let (world, repo) = issue_world().await; - let (issue_uri, issue_cid) = opened(&world, &world.stranger, &repo, "kelp").await; + let (world, repo) = ticket_world().await; let contribution = Contribution::on(&world, &repo); + let issue = contribution.opened(&world.stranger, "kelp").await; - let written = ok(contribution - .comment(&world.stranger, &issue_uri, &issue_cid, "a drifter") - .await); + let written = ok(issue.comment(&world.stranger, "a drifter").await); let comment_uri = uri_of(&written); let v1 = cid_of(&written); assert!( @@ -5951,10 +6163,10 @@ async fn comment_opens_edits_and_deletes_along_its_version_chain() { ); let record = ok(record_at(&world, &repo, &comment_uri, None).await); assert_eq!(record["value"]["$type"], COMMENT); - assert_eq!(record["value"]["subject"]["uri"], issue_uri.as_str()); + assert_eq!(record["value"]["subject"]["uri"], issue.uri.as_str()); assert_eq!( record["value"]["subject"]["cid"]["$link"], - issue_cid.to_string(), + issue.cid.to_string(), "subject strongRef must name the exact issue versoon the comment was made on" ); assert_eq!(record["value"]["body"]["$type"], MARKUP); @@ -5967,15 +6179,8 @@ async fn comment_opens_edits_and_deletes_along_its_version_chain() { let versions = versions_page(&world, &comment_uri, None).await; assert_eq!(versions["versions"].as_array().unwrap().len(), 1); - let reply = ok(contribution - .replying( - &world.stranger, - &issue_uri, - &issue_cid, - &comment_uri, - &v1, - "on the drifter", - ) + let reply = ok(issue + .replying(&world.stranger, &comment_uri, &v1, "on the drifter") .await); let reply_uri = uri_of(&reply); let reply_record = ok(record_at(&world, &repo, &reply_uri, None).await); @@ -5984,43 +6189,22 @@ async fn comment_opens_edits_and_deletes_along_its_version_chain() { comment_uri.as_str() ); - let edited = ok(contribution - .edit( - &world.stranger, - &comment_uri, - &v1, - &issue_uri, - &issue_cid, - "the drifter", - ) + let edited = ok(issue + .edit(&world.stranger, &comment_uri, &v1, "the drifter") .await); let v2 = cid_of(&edited); assert_ne!(v1, v2); let stale = failed( - contribution - .edit( - &world.stranger, - &comment_uri, - &v1, - &issue_uri, - &issue_cid, - "the late drifter", - ) + issue + .edit(&world.stranger, &comment_uri, &v1, "the late drifter") .await, StatusCode::CONFLICT, ); assert_eq!(stale["error"], "InvalidSwap"); - let maintained = ok(contribution - .edit( - &world.member, - &comment_uri, - &v2, - &issue_uri, - &issue_cid, - "the drifter, kept", - ) + let maintained = ok(issue + .edit(&world.member, &comment_uri, &v2, "the drifter, kept") .await); let v3 = cid_of(&maintained); assert_eq!( @@ -6028,21 +6212,12 @@ async fn comment_opens_edits_and_deletes_along_its_version_chain() { world.member.did.as_str() ); - let owned_by_member = ok(contribution - .comment(&world.member, &issue_uri, &issue_cid, "owner's note") - .await); + let owned_by_member = ok(issue.comment(&world.member, "owner's note").await); let owned_uri = uri_of(&owned_by_member); let owned_v1 = cid_of(&owned_by_member); let denied = failed( - contribution - .edit( - &world.stranger, - &owned_uri, - &owned_v1, - &issue_uri, - &issue_cid, - "rewritten", - ) + issue + .edit(&world.stranger, &owned_uri, &owned_v1, "rewritten") .await, StatusCode::FORBIDDEN, ); @@ -6070,39 +6245,67 @@ async fn comment_opens_edits_and_deletes_along_its_version_chain() { assert!(chain[3].get("cid").is_none()); } +#[tokio::test] +async fn a_served_comment_round_trips_through_put_record() { + let (world, repo) = ticket_world().await; + let contribution = Contribution::on(&world, &repo); + let issue = contribution.opened(&world.stranger, "kelp").await; + let written = ok(issue.comment(&world.stranger, "a drifter").await); + let comment_uri = uri_of(&written); + let v1 = cid_of(&written); + let served = ok(record_at(&world, &repo, &comment_uri, None).await); + let echoed = served["value"].clone(); + assert_eq!( + echoed["body"]["$type"], MARKUP, + "the served body is the lexicon's markdown union" + ); + let verbatim = ok(contribution + .put(&world.stranger, &comment_uri, &v1, echoed.clone()) + .await); + assert_eq!( + cid_of(&verbatim), + v1, + "a comment echoed back verbatim serves the record that already exists" + ); + + let mut rewritten = echoed; + rewritten["body"]["text"] = json!("the drifter"); + ok(contribution + .put(&world.stranger, &comment_uri, &v1, rewritten) + .await); + assert_eq!( + ok(record_at(&world, &repo, &comment_uri, None).await)["value"]["body"]["text"], + serde_json::json!("the drifter"), + "a comment echoed back with the union shape kept writes the new text" + ); +} + #[tokio::test] async fn comment_rejects_subjects_this_knot_doesnt_serve() { - let (world, repo) = issue_world().await; - let (issue_uri, v1) = opened(&world, &world.stranger, &repo, "kelp").await; + let (world, repo) = ticket_world().await; let contribution = Contribution::on(&world, &repo); + let issue = contribution.opened(&world.stranger, "kelp").await; + let ticket = Ticket::on(&world, &repo); let missing = at_of( &repo, - knot_types::RecordCollection::new(ISSUE).unwrap(), + knot_types::RecordCollection::new(TICKET).unwrap(), "3lubrptx57d33", ); let rejected = failed( contribution - .comment(&world.stranger, &missing, &v1, "nope") + .comment(&world.stranger, &missing, &issue.cid, "nope") .await, StatusCode::BAD_REQUEST, ); message_contains(&rejected, &["isn't a record on this knot"]); - let edited_issue = ok(edit_issue( - &world, - &world.stranger, - &repo, - &issue_uri, - &v1, - "kelp, again", - ) - .await); - let standing = cid_of(&edited_issue); + let edited_issue = ok(ticket + .edit(&world.stranger, &issue.uri, &issue.cid, "kelp, again") + .await); + let current = cid_of(&edited_issue); let stale_subject = failed( - contribution - .comment(&world.stranger, &issue_uri, &v1, "nope") - .await, + issue.comment(&world.stranger, "nope").await, StatusCode::BAD_REQUEST, ); message_contains(&stale_subject, &["strongRef CID is", "now has version"]); @@ -6110,23 +6313,23 @@ async fn comment_rejects_subjects_this_knot_doesnt_serve() { stale_subject["message"] .as_str() .unwrap() - .contains(&standing.to_string()) + .contains(¤t.to_string()) ); let on_state = at_of( &repo, - knot_types::RecordCollection::new(ISSUE_STATE).unwrap(), + knot_types::RecordCollection::new("com.example.gripe").unwrap(), "3lubrptx57d44", ); let wrong_collection = failed( contribution - .comment(&world.stranger, &on_state, &standing, "nope") + .comment(&world.stranger, &on_state, ¤t, "nope") .await, StatusCode::BAD_REQUEST, ); message_contains( &wrong_collection, - &["takes issues and pulls as comment subjects"], + &["takes tickets and patches as comment subjects"], ); let legacy = failed( @@ -6137,7 +6340,7 @@ async fn comment_rejects_subjects_this_knot_doesnt_serve() { LEGACY_COMMENT, None, retype( - comment_record(&issue_uri, &standing, "old world"), + comment_record(&issue.uri, ¤t, "old world"), LEGACY_COMMENT, ), ) @@ -6153,7 +6356,7 @@ async fn comment_rejects_subjects_this_knot_doesnt_serve() { &repo, COMMENT, Some("3lubrptx57d55".to_owned()), - comment_record(&issue_uri, &standing, "mine"), + comment_record(&issue.uri, ¤t, "mine"), ) .await, StatusCode::BAD_REQUEST, @@ -6162,7 +6365,7 @@ async fn comment_rejects_subjects_this_knot_doesnt_serve() { let empty = failed( contribution - .comment(&world.stranger, &issue_uri, &standing, "") + .comment(&world.stranger, &issue.uri, ¤t, "") .await, StatusCode::BAD_REQUEST, ); @@ -6172,10 +6375,10 @@ async fn comment_rejects_subjects_this_knot_doesnt_serve() { contribution .replying( &world.stranger, - &issue_uri, - &standing, - &issue_uri, - &standing, + &issue.uri, + ¤t, + &issue.uri, + ¤t, "nope", ) .await, @@ -6183,16 +6386,12 @@ async fn comment_rejects_subjects_this_knot_doesnt_serve() { ); message_contains(&replied_wrong, &["the uri's collection is", "takes"]); - ok(transition( - &world, - &world.stranger, - &repo, - &issue_uri, - IssueState::Closed, - ) - .await); + let closed = ok(ticket + .set_state(&world.stranger, &issue.uri, ¤t, TicketState::Closed) + .await); + let closed_cid = cid_of(&closed); ok(contribution - .comment(&world.stranger, &issue_uri, &standing, "still fine") + .comment(&world.stranger, &issue.uri, &closed_cid, "still fine") .await); } @@ -6203,11 +6402,11 @@ fn at_of(repo: &RepoDid, collection: knot_types::RecordCollection, rkey: &str) - #[tokio::test] async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { - let (world, repo) = issue_world().await; - let (issue_uri, _) = opened(&world, &world.stranger, &repo, "kelp").await; + let (world, repo) = ticket_world().await; let contribution = Contribution::on(&world, &repo); + let issue = contribution.opened(&world.stranger, "kelp").await; - let liked = ok(contribution.react(&world.stranger, &issue_uri, "❤️").await); + let liked = ok(issue.react(&world.stranger, "❤️").await); let like_uri = uri_of(&liked); let like_v1 = cid_of(&liked); assert!( @@ -6217,14 +6416,14 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { ); let record = ok(record_at(&world, &repo, &like_uri, None).await); assert_eq!(record["value"]["$type"], REACTION); - assert_eq!(record["value"]["subject"], issue_uri.as_str()); + assert_eq!(record["value"]["subject"]["uri"], issue.uri.as_str()); assert_eq!(record["value"]["reaction"], "❤️"); assert_eq!( record["value"]["x-tngl-editor"], world.stranger.did.as_str() ); - let again = ok(contribution.react(&world.stranger, &issue_uri, "❤️").await); + let again = ok(issue.react(&world.stranger, "❤️").await); assert_eq!(again["uri"], like_uri.as_str()); assert_eq!(again["cid"], like_v1.to_string()); assert_eq!( @@ -6233,10 +6432,10 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { ); assert_eq!(records_in(&world, &repo, REACTION).await.len(), 1); - let rocket = ok(contribution.react(&world.stranger, &issue_uri, "🚀").await); + let rocket = ok(issue.react(&world.stranger, "🚀").await); let rocket_uri = uri_of(&rocket); assert_ne!(rocket_uri, like_uri); - let members = ok(contribution.react(&world.member, &issue_uri, "❤️").await); + let members = ok(issue.react(&world.member, "❤️").await); let members_uri = uri_of(&members); assert_ne!(members_uri, like_uri); assert_ne!(members_uri, rocket_uri); @@ -6250,7 +6449,11 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { REACTION, rkey_of(&like_uri), json!({ "swapRecord": like_v1 }), - json!({ "$type": REACTION, "subject": issue_uri, "reaction": "🚀" }), + json!({ + "$type": REACTION, + "subject": { "uri": issue.uri, "cid": issue.cid }, + "reaction": "🚀" + }), ) .await, StatusCode::BAD_REQUEST, @@ -6265,7 +6468,7 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(gone["error"], "RecordNotFound"); - let reminted = ok(contribution.react(&world.stranger, &issue_uri, "❤️").await); + let reminted = ok(issue.react(&world.stranger, "❤️").await); assert_ne!( uri_of(&reminted), like_uri, @@ -6283,26 +6486,41 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { #[tokio::test] async fn reaction_opens_only_on_live_subjects() { - let (world, repo) = issue_world().await; - let (issue_uri, v1) = opened(&world, &world.stranger, &repo, "kelp").await; + let (world, repo) = ticket_world().await; let contribution = Contribution::on(&world, &repo); - let liked = ok(contribution.react(&world.member, &issue_uri, "❤️").await); + let issue = contribution.opened(&world.stranger, "kelp").await; + let ticket = Ticket::on(&world, &repo); + let liked = ok(issue.react(&world.member, "❤️").await); let like_uri = uri_of(&liked); + let edited = ok(ticket + .edit(&world.stranger, &issue.uri, &issue.cid, "kelp, again") + .await); + let v2 = cid_of(&edited); + let stale = failed( + issue.react(&world.stranger, "👍").await, + StatusCode::BAD_REQUEST, + ); + message_contains(&stale, &["strongRef CID", &format!("now has version {v2}")]); + let missing = at_of( &repo, - knot_types::RecordCollection::new(ISSUE).unwrap(), + knot_types::RecordCollection::new(TICKET).unwrap(), "3lubrptx57d33", ); let rejected = failed( - contribution.react(&world.stranger, &missing, "👍").await, + contribution + .react(&world.stranger, &missing, &v2, "👍") + .await, StatusCode::BAD_REQUEST, ); message_contains(&rejected, &["isn't a record on this knot"]); - ok(delete_issue(&world, &world.stranger, &repo, &issue_uri, &v1).await); + ok(ticket.erase(&world.stranger, &issue.uri, &v2).await); let erased = failed( - contribution.react(&world.member, &issue_uri, "❤️").await, + contribution + .react(&world.member, &issue.uri, &v2, "❤️") + .await, StatusCode::BAD_REQUEST, ); message_contains(&erased, &["was deleted"]); @@ -6315,18 +6533,18 @@ async fn reaction_opens_only_on_live_subjects() { #[tokio::test] async fn comment_outlives_its_subject_on_record_reads() { - let (world, repo) = issue_world().await; - let (issue_uri, issue_v1) = opened(&world, &world.stranger, &repo, "kelp").await; + let (world, repo) = ticket_world().await; let contribution = Contribution::on(&world, &repo); + let issue = contribution.opened(&world.stranger, "kelp").await; - let written = ok(contribution - .comment(&world.stranger, &issue_uri, &issue_v1, "a drifter") - .await); + let written = ok(issue.comment(&world.stranger, "a drifter").await); let comment_uri = uri_of(&written); let comment_v1 = cid_of(&written); - let (_, issue_address) = knot_types::RecordAddress::parse_at_uri(&issue_uri).unwrap(); + let (_, ticket_address) = knot_types::RecordAddress::parse_at_uri(&issue.uri).unwrap(); - ok(delete_issue(&world, &world.stranger, &repo, &issue_uri, &issue_v1).await); + ok(Ticket::on(&world, &repo) + .erase(&world.stranger, &issue.uri, &issue.cid) + .await); assert_eq!( ok(record_at(&world, &repo, &comment_uri, None).await)["cid"], @@ -6337,7 +6555,7 @@ async fn comment_outlives_its_subject_on_record_reads() { records_in(&world, &repo, COMMENT).await, vec![comment_uri.as_str().to_owned()] ); - assert!(records_in(&world, &repo, ISSUE).await.is_empty()); + assert!(records_in(&world, &repo, TICKET).await.is_empty()); assert_eq!( versions_page(&world, &comment_uri, None).await["versions"] .as_array() @@ -6346,7 +6564,7 @@ async fn comment_outlives_its_subject_on_record_reads() { 1 ); assert_eq!( - world.state.index.social_children_of(&repo, &issue_address), + world.state.index.social_children_of(&repo, &ticket_address), knot_index::Resolved::Ready(Vec::new()), "the index shouldn't list children of a deleted issue" ); @@ -6354,9 +6572,9 @@ async fn comment_outlives_its_subject_on_record_reads() { #[tokio::test] async fn comment_rejects_extras_it_cant_store() { - let (world, repo) = issue_world().await; - let (issue_uri, issue_cid) = opened(&world, &world.stranger, &repo, "kelp").await; + let (world, repo) = ticket_world().await; let contribution = Contribution::on(&world, &repo); + let issue = contribution.opened(&world.stranger, "kelp").await; let rejected_with = |record: serde_json::Value| { let world = &world; @@ -6369,54 +6587,36 @@ async fn comment_rejects_extras_it_cant_store() { } }; - let mut rounded = comment_record(&issue_uri, &issue_cid, "with a round"); - rounded["pullRoundIdx"] = json!(0); - message_contains( - &rejected_with(rounded).await, - &["pullRoundIdx", "this subject is an issue"], - ); - - let mut blank = comment_record(&issue_uri, &issue_cid, "kelp"); - blank["body"]["original"] = json!(" "); - message_contains(&rejected_with(blank).await, &["original is unusable"]); - - let mut embedded = comment_record(&issue_uri, &issue_cid, "with an embed"); - embedded["embed"] = json!({ "$type": "sh.tangled.embed.commit", "commit": {} }); + let mut embedded = comment_record(&issue.uri, &issue.cid, "with an embed"); + embedded["embed"] = json!({ "$type": "org.tangled.embed.commit", "commit": {} }); message_contains( &rejected_with(embedded).await, &["embed is an attachment", "leave embed out"], ); - let mut malformed = comment_record(&issue_uri, &issue_cid, "with a malformed blob"); + let mut malformed = comment_record(&issue.uri, &issue.cid, "with a malformed blob"); malformed["body"]["blobs"] = json!([{ - "$type": "blob", - "ref": { "$link": "not-a-cid" }, - "mimeType": "image/png", - "size": 5 + "$type": "org.tangled.markup.embed.image", + "image": { + "ref": { "$link": "not-a-cid" }, + "mimeType": "image/png", + "size": 5 + } }]); message_contains(&rejected_with(malformed).await, &["unusable"]); } #[tokio::test] async fn comment_subject_and_reply_are_fixed_at_open() { - let (world, repo) = issue_world().await; - let (issue_uri, issue_cid) = opened(&world, &world.stranger, &repo, "kelp").await; + let (world, repo) = ticket_world().await; let contribution = Contribution::on(&world, &repo); + let issue = contribution.opened(&world.stranger, "kelp").await; - let drifter = ok(contribution - .comment(&world.stranger, &issue_uri, &issue_cid, "a drifter") - .await); + let drifter = ok(issue.comment(&world.stranger, "a drifter").await); let drifter_uri = uri_of(&drifter); let drifter_v1 = cid_of(&drifter); - let reply = ok(contribution - .replying( - &world.stranger, - &issue_uri, - &issue_cid, - &drifter_uri, - &drifter_v1, - "on the drifter", - ) + let reply = ok(issue + .replying(&world.stranger, &drifter_uri, &drifter_v1, "on the drifter") .await); let reply_uri = uri_of(&reply); let reply_v1 = cid_of(&reply); @@ -6424,68 +6624,56 @@ async fn comment_subject_and_reply_are_fixed_at_open() { serde_json::from_value(json!(knot_types::dag_cbor_cid(b"never minted").to_string())) .unwrap(); - let swapped_subject = failed( - contribution - .put(&world.stranger, &reply_uri, &reply_v1, { - let mut record = comment_record(&issue_uri, &issue_cid, "on the drifter, kept"); + for (uri, from, record, want) in [ + ( + &reply_uri, + &reply_v1, + { + let mut record = comment_record(&issue.uri, &issue.cid, "on the drifter, kept"); record["subject"]["cid"] = json!(never.to_string()); record - }) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&swapped_subject, &["subject is set at creation"]); - - let swapped_reply = failed( - contribution - .put( - &world.stranger, - &reply_uri, - &reply_v1, - comment_record_replying( - &issue_uri, - &issue_cid, - &drifter_uri, - &never, - "on the drifter, kept", - ), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&swapped_reply, &["reply is set at creation"]); - - let dropped_reply = failed( - contribution - .put( - &world.stranger, + }, + "subject is set at creation", + ), + ( + &reply_uri, + &reply_v1, + comment_record_replying( + &issue.uri, + &issue.cid, + &drifter_uri, + &never, + "on the drifter, kept", + ), + "reply is set at creation", + ), + ( + &reply_uri, + &reply_v1, + comment_record(&issue.uri, &issue.cid, "on the drifter, kept"), + "reply is set at creation", + ), + ( + &drifter_uri, + &drifter_v1, + comment_record_replying( + &issue.uri, + &issue.cid, &reply_uri, &reply_v1, - comment_record(&issue_uri, &issue_cid, "on the drifter, kept"), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&dropped_reply, &["reply is set at creation"]); - - let gained_reply = failed( - contribution - .put( - &world.stranger, - &drifter_uri, - &drifter_v1, - comment_record_replying( - &issue_uri, - &issue_cid, - &reply_uri, - &reply_v1, - "the drifter, kept", - ), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&gained_reply, &["reply is set at creation"]); + "the drifter, kept", + ), + "reply is set at creation", + ), + ] { + message_contains( + &failed( + contribution.put(&world.stranger, uri, from, record).await, + StatusCode::BAD_REQUEST, + ), + &[want], + ); + } let kept = ok(contribution .put( @@ -6493,8 +6681,8 @@ async fn comment_subject_and_reply_are_fixed_at_open() { &reply_uri, &reply_v1, comment_record_replying( - &issue_uri, - &issue_cid, + &issue.uri, + &issue.cid, &drifter_uri, &drifter_v1, "on the drifter, kept", @@ -6503,26 +6691,17 @@ async fn comment_subject_and_reply_are_fixed_at_open() { .await); assert_ne!(cid_of(&kept), reply_v1); - let drifter_v2 = cid_of(&ok(contribution + let drifter_v2 = cid_of(&ok(issue .edit( &world.stranger, &drifter_uri, &drifter_v1, - &issue_uri, - &issue_cid, "the drifter, kept", ) .await)); let stale = failed( - contribution - .replying( - &world.stranger, - &issue_uri, - &issue_cid, - &drifter_uri, - &drifter_v1, - "on the drifter", - ) + issue + .replying(&world.stranger, &drifter_uri, &drifter_v1, "on the drifter") .await, StatusCode::BAD_REQUEST, ); @@ -6534,49 +6713,36 @@ async fn comment_subject_and_reply_are_fixed_at_open() { .contains(&drifter_v2.to_string()) ); - ok(contribution - .replying( - &world.stranger, - &issue_uri, - &issue_cid, - &drifter_uri, - &drifter_v2, - "on the drifter", - ) + ok(issue + .replying(&world.stranger, &drifter_uri, &drifter_v2, "on the drifter") .await); } #[tokio::test] async fn reaction_accepts_any_live_record_as_subject() { - let (world, repo) = issue_world().await; - let (issue_uri, issue_cid) = opened(&world, &world.stranger, &repo, "kelp").await; + let (world, repo) = ticket_world().await; let contribution = Contribution::on(&world, &repo); + let issue = contribution.opened(&world.stranger, "kelp").await; - let drifter = ok(contribution - .comment(&world.stranger, &issue_uri, &issue_cid, "a drifter") - .await); + let drifter = ok(issue.comment(&world.stranger, "a drifter").await); let drifter_uri = uri_of(&drifter); - let closed = ok(transition( - &world, - &world.stranger, - &repo, - &issue_uri, - IssueState::Closed, - ) - .await); - let state_uri = uri_of(&closed); + let drifter_cid = cid_of(&drifter); - ok(contribution.react(&world.member, &drifter_uri, "👀").await); - ok(contribution.react(&world.member, &state_uri, "🚀").await); + ok(contribution + .react(&world.member, &drifter_uri, &drifter_cid, "👀") + .await); let on_comment = ok(contribution - .react(&world.stranger, &drifter_uri, "👍") + .react(&world.stranger, &drifter_uri, &drifter_cid, "👍") .await); let reaction_uri = uri_of(&on_comment); - ok(contribution.react(&world.member, &reaction_uri, "❤️").await); + let reaction_cid = cid_of(&on_comment); + ok(contribution + .react(&world.member, &reaction_uri, &reaction_cid, "❤️") + .await); - assert_eq!(records_in(&world, &repo, REACTION).await.len(), 4); + assert_eq!(records_in(&world, &repo, REACTION).await.len(), 3); assert_eq!( - ok(record_at(&world, &repo, &reaction_uri, None).await)["value"]["subject"], + ok(record_at(&world, &repo, &reaction_uri, None).await)["value"]["subject"]["uri"], drifter_uri.as_str(), "a reaction can target any existing record in the repository" ); @@ -6584,26 +6750,17 @@ async fn reaction_accepts_any_live_record_as_subject() { #[tokio::test] async fn no_op_edit_by_stranger_is_still_forbidden() { - let (world, repo) = issue_world().await; - let (issue_uri, issue_cid) = opened(&world, &world.stranger, &repo, "kelp").await; + let (world, repo) = ticket_world().await; let contribution = Contribution::on(&world, &repo); + let issue = contribution.opened(&world.stranger, "kelp").await; - let owned = ok(contribution - .comment(&world.member, &issue_uri, &issue_cid, "owner's note") - .await); + let owned = ok(issue.comment(&world.member, "owner's note").await); let owned_uri = uri_of(&owned); let owned_v1 = cid_of(&owned); let denied = failed( - contribution - .edit( - &world.stranger, - &owned_uri, - &owned_v1, - &issue_uri, - &issue_cid, - "owner's note", - ) + issue + .edit(&world.stranger, &owned_uri, &owned_v1, "owner's note") .await, StatusCode::FORBIDDEN, ); @@ -6617,15 +6774,8 @@ async fn no_op_edit_by_stranger_is_still_forbidden() { "the rejected edit shouldn't add a version" ); - let minted = ok(contribution - .edit( - &world.member, - &owned_uri, - &owned_v1, - &issue_uri, - &issue_cid, - "owner's note", - ) + let minted = ok(issue + .edit(&world.member, &owned_uri, &owned_v1, "owner's note") .await); assert_eq!(cid_of(&minted), owned_v1); let page = versions_page(&world, &owned_uri, None).await; @@ -6640,11 +6790,11 @@ async fn no_op_edit_by_stranger_is_still_forbidden() { #[tokio::test] async fn reaction_dedup_wont_answer_when_policy_rejects() { - let (world, repo) = issue_world().await; - let (issue_uri, _) = opened(&world, &world.stranger, &repo, "kelp").await; + let (world, repo) = ticket_world().await; let contribution = Contribution::on(&world, &repo); + let issue = contribution.opened(&world.stranger, "kelp").await; - let liked = ok(contribution.react(&world.stranger, &issue_uri, "❤️").await); + let liked = ok(issue.react(&world.stranger, "❤️").await); let like_uri = uri_of(&liked); assert_eq!( @@ -6658,7 +6808,7 @@ async fn reaction_dedup_wont_answer_when_policy_rejects() { StatusCode::OK ); let shut_out = failed( - contribution.react(&world.stranger, &issue_uri, "❤️").await, + issue.react(&world.stranger, "❤️").await, StatusCode::FORBIDDEN, ); assert!( @@ -6672,7 +6822,7 @@ async fn reaction_dedup_wont_answer_when_policy_rejects() { set_policy_as(&world, &world.member, &repo, ContributionPolicy::Anyone).await, StatusCode::OK ); - let again = ok(contribution.react(&world.stranger, &issue_uri, "❤️").await); + let again = ok(issue.react(&world.stranger, "❤️").await); assert_eq!(again["uri"], like_uri.as_str()); assert_eq!( records_in(&world, &repo, REACTION).await.len(), @@ -6681,44 +6831,18 @@ async fn reaction_dedup_wont_answer_when_policy_rejects() { ); } -const LABEL_DEF: &str = "sh.tangled.label.definition"; -const LABEL_OP: &str = "sh.tangled.label.op"; +const LABEL_DEF: &str = "org.tangled.label.definition"; -fn def_body(name: &str, scope: &[&str], value_type: serde_json::Value) -> serde_json::Value { - json!({ +fn def_body(name: &str, color: Option<&str>) -> serde_json::Value { + let mut body = json!({ "$type": LABEL_DEF, "name": name, - "scope": scope, - "multiple": false, "createdAt": "2026-09-01T00:00:00Z", - "valueType": value_type, - }) -} - -fn null_type() -> serde_json::Value { - json!({"type": "null", "format": "any"}) -} - -fn assignee_type() -> serde_json::Value { - json!({"type": "string", "format": "did"}) -} - -fn op_body( - subject: &AtUri, - add: serde_json::Value, - delete: serde_json::Value, -) -> serde_json::Value { - json!({ - "$type": LABEL_OP, - "subject": subject.as_str(), - "add": add, - "delete": delete, - "performedAt": "2026-09-01T00:00:00Z", - }) -} - -fn operand(key: &str, value: &str) -> serde_json::Value { - json!({ "key": key, "value": value }) + }); + if let Some(color) = color { + body["color"] = json!(color); + } + body } async fn define_label( @@ -6726,54 +6850,27 @@ async fn define_label( repo: &RepoDid, slug: &str, name: &str, - scope: &[&str], - value_type: serde_json::Value, - multiple: bool, + color: Option<&str>, ) -> (AtUri, crate::out::CidString) { - let mut body = def_body(name, scope, value_type); - body["multiple"] = json!(multiple); let written = ok(create_as( world, &world.member, repo, LABEL_DEF, Some(slug.to_owned()), - body, + def_body(name, color), ) .await); (uri_of(&written), cid_of(&written)) } async fn wontfix(world: &World, repo: &RepoDid) -> (AtUri, crate::out::CidString) { - define_label( - world, - repo, - "wontfix", - "wontfix", - &["sh.tangled.repo.issue"], - null_type(), - false, - ) - .await -} - -fn remote_triage(world: &World, rkey: &str) -> String { - world.publish_remote_def( - rkey, - json!({ - "name": rkey, - "scope": ["sh.tangled.repo.issue"], - "multiple": true, - "createdAt": "2026-09-01T00:00:00Z", - "valueType": {"type": "string", "format": "any"} - }), - ); - format!("at://{}/{LABEL_DEF}/{rkey}", world.stranger.did.as_str()) + define_label(world, repo, "wontfix", "wontfix", Some("#79ffe1")).await } #[tokio::test] async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { - let (world, repo) = issue_world().await; + let (world, repo) = ticket_world().await; let refused = failed( create_as( @@ -6782,7 +6879,7 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { &repo, LABEL_DEF, Some("wontfix".to_owned()), - def_body("wontfix", &["sh.tangled.repo.issue"], null_type()), + def_body("wontfix", Some("#79ffe1")), ) .await, StatusCode::FORBIDDEN, @@ -6796,7 +6893,7 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { &repo, LABEL_DEF, Some("wontfix".to_owned()), - def_body("wontfix", &["sh.tangled.repo.issue"], null_type()), + def_body("wontfix", Some("#79ffe1")), ) .await, StatusCode::FORBIDDEN, @@ -6813,7 +6910,7 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { &repo, LABEL_DEF, None, - def_body("wontfix", &["sh.tangled.repo.issue"], null_type()), + def_body("wontfix", Some("#79ffe1")), ) .await, StatusCode::BAD_REQUEST, @@ -6832,6 +6929,7 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { ); let served = ok(record_at(&world, &repo, &uri, None).await); assert_eq!(served["value"]["name"], "wontfix"); + assert_eq!(served["value"]["color"], "#79FFE1"); assert_eq!(served["value"]["x-tngl-editor"], world.member.did.as_str()); let duplicate = failed( @@ -6841,7 +6939,7 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { &repo, LABEL_DEF, Some("wontfix".to_owned()), - def_body("wontfix2", &["sh.tangled.repo.issue"], null_type()), + def_body("wontfix2", Some("#79ffe1")), ) .await, StatusCode::CONFLICT, @@ -6855,7 +6953,7 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { LABEL_DEF, "wontfix".to_owned(), json!({ "swapRecord": cid }), - def_body("never_happening", &["sh.tangled.repo.issue"], null_type()), + def_body("never_happening", Some("#ff0000")), ) .await); assert_eq!(uri_of(&edited).as_str(), uri.as_str()); @@ -6870,7 +6968,7 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { LABEL_DEF, "wontfix".to_owned(), json!({ "swapRecord": cid_of(&edited) }), - def_body("taken_over", &["sh.tangled.repo.issue"], null_type()), + def_body("taken_over", Some("#79ffe1")), ) .await, StatusCode::FORBIDDEN, @@ -6897,690 +6995,85 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { } #[tokio::test] -async fn label_ops_require_triage_and_vet_their_operands() { - let (world, repo) = issue_world().await; - let (issue, _) = opened(&world, &world.stranger, &repo, "kelp").await; - let (assignee, _) = define_label( +async fn erased_def_slug_stays_retired() { + let (world, repo) = ticket_world().await; + let (_, cid) = wontfix(&world, &repo).await; + ok(delete_as( &world, + &world.member, &repo, - "assignee", - "assignee", - &["sh.tangled.repo.issue", "sh.tangled.repo.pull"], - assignee_type(), - true, + LABEL_DEF, + "wontfix".to_owned(), + json!({ "swapRecord": cid }), ) - .await; - let (wontfix, _) = wontfix(&world, &repo).await; - + .await); let refused = failed( create_as( &world, - &world.stranger, + &world.member, &repo, - LABEL_OP, - None, - op_body( - &issue, - json!([operand(wontfix.as_str(), "null")]), - json!([]), - ), + LABEL_DEF, + Some("wontfix".to_owned()), + def_body("wontfix", Some("#79ffe1")), ) .await, - StatusCode::FORBIDDEN, + StatusCode::CONFLICT, + ); + message_contains(&refused, &["was erased at", "slug stays retired"]); + assert_eq!( + records_in(&world, &repo, LABEL_DEF).await, + Vec::::new(), + "the erased def doesn't serve, and the slug stays retired: one record address is \ + one object for life" ); +} +#[tokio::test] +async fn tickets_cite_label_defs_the_repository_actually_serves() { + let (world, repo) = ticket_world().await; + let ticket = Ticket::on(&world, &repo); + let (uri, v1) = ticket.opened(&world.stranger, "kelp").await; + message_contains( - &refused, - &[ - "labels belong to the repository's owner and collaborators", - "add you as a collaborator", - ], + &ticket + .refused_create(TicketDraft::fresh(&repo).citing("wontfix").record) + .await, + &["wontfix", "isn't a label def on this repository"], ); - let wrong_value = failed( - create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &issue, - json!([operand(assignee.as_str(), "squid")]), - json!([]), - ), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&wrong_value, &["did-typed label's value is a DID"]); - - let missing_subject = failed( - create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &AtUri::new_owned(format!("at://{repo}/{ISSUE}/3lubrptx5zzzz")).unwrap(), - json!([operand(wontfix.as_str(), "null")]), - json!([]), - ), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&missing_subject, &["isn't a record on this knot"]); - - let gone_def = failed( - create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &issue, - json!([operand(&format!("at://{repo}/{LABEL_DEF}/absent"), "null")]), - json!([]), - ), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&gone_def, &["doesn't have a label def at"]); - - let first = ok(create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &issue, - json!([ - operand(assignee.as_str(), "did:plc:abalone"), - operand(wontfix.as_str(), "null"), - ]), - json!([]), - ), - ) - .await); - let op_uri = uri_of(&first); - assert_eq!( - op_uri.as_str(), - format!("at://{repo}/{LABEL_OP}/{}", rkey_of(&op_uri)), - "the op's record key is the minted TID, like every social record but the def" - ); - - let second = ok(create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &issue, - json!([operand(assignee.as_str(), "did:plc:conch")]), - json!([]), - ), - ) - .await); - let third = ok(create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &issue, - json!([]), - json!([operand(assignee.as_str(), "did:plc:abalone")]), - ), - ) - .await); - assert_ne!(cid_of(&second), cid_of(&third)); - - let labels = labels_on(&world, &repo, &issue); - assert_eq!( - labels, - std::collections::BTreeMap::from([ - ( - format!("at://{repo}/{LABEL_DEF}/assignee"), - vec!["did:plc:conch".to_owned()] - ), - ( - format!("at://{repo}/{LABEL_DEF}/wontfix"), - vec!["null".to_owned()] - ), - ]), - "multiple accumulates then a delete removes one value, and single keeps one standing value" - ); - - let not_edited = failed( - put_as( - &world, - &world.member, - &repo, - LABEL_OP, - rkey_of(&op_uri), - json!({ "swapRecord": cid_of(&first) }), - op_body( - &issue, - json!([operand(wontfix.as_str(), "null")]), - json!([]), - ), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(¬_edited, &["putRecord can't edit"]); - - ok(delete_as( - &world, - &world.member, - &repo, - LABEL_OP, - rkey_of(&op_uri), - json!({ "swapRecord": cid_of(&first) }), - ) - .await); - let after_erase = labels_on(&world, &repo, &issue); - assert_eq!( - after_erase, - std::collections::BTreeMap::from([( - format!("at://{repo}/{LABEL_DEF}/assignee"), - vec!["did:plc:conch".to_owned()] - ),]), - "erasing the first op removes the two applications, and the standing set is what the \ - surviving ops fold to" - ); - - let mut echoed = op_body( - &issue, - json!([operand(wontfix.as_str(), "null")]), - json!([]), - ); - echoed["x-tngl-editor"] = json!("did:plc:system"); - let response = create_as(&world, &world.member, &repo, LABEL_OP, None, echoed).await; - assert_eq!( - response.0, - StatusCode::OK, - "the server stamps the editor, and a client echoing the field back is heard and \ - overwritten: {response:?}" - ); -} - -#[tokio::test] -async fn remote_def_reads_through_getrecord_and_an_unreadable_def_rejects_the_op() { - let (world, repo) = issue_world().await; - let (issue, _) = opened(&world, &world.stranger, &repo, "kelp").await; - - let remote = remote_triage(&world, "triage"); - - ok(create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &issue, - json!([ - operand(&remote, "needs-a-look"), - operand(&remote, "next-up") - ]), - json!([]), - ), - ) - .await); - let reads_after_first = world.record_reads(); - ok(create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body(&issue, json!([]), json!([operand(&remote, "needs-a-look")])), - ) - .await); - assert!( - world.record_reads() <= reads_after_first + 1, - "the def caches the way public keys do, so a second op costs at most one more \ - getRecord" - ); - - let labels = labels_on(&world, &repo, &issue); - assert_eq!( - labels, - std::collections::BTreeMap::from([(remote.clone(), vec!["next-up".to_owned()])]) - ); - - let unreadable = format!("at://{}/{LABEL_DEF}/gone", world.stranger.did.as_str()); let refused = failed( - create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body(&issue, json!([operand(&unreadable, "null")]), json!([])), - ) - .await, - StatusCode::BAD_GATEWAY, - ); - message_contains(&refused, &["didn't serve", "won't store"]); - assert_eq!( - records_in(&world, &repo, LABEL_OP).await.len(), - 2, - "the refused op doesn't leave a record behind" - ); -} - -#[tokio::test] -async fn refused_label_ops_leave_the_defs_unread() { - let (world, repo) = issue_world().await; - let (issue, _) = opened(&world, &world.stranger, &repo, "kelp").await; - - let remote = remote_triage(&world, "triage"); - let before = world.record_reads(); - let refused = failed( - create_as( - &world, - &world.stranger, - &repo, - LABEL_OP, - None, - op_body(&issue, json!([operand(&remote, "needs-a-look")]), json!([])), - ) - .await, - StatusCode::FORBIDDEN, - ); - message_contains( - &refused, - &["labels belong to the repository's owner and collaborators"], - ); - assert_eq!( - world.record_reads(), - before, - "the triage rule rejects the write before any def is read, so a stranger's write \ - costs zero getRecord calls" - ); - - let bogus = AtUri::new_owned(format!("at://{repo}/{ISSUE}/3lubrptx5zzzz")).unwrap(); - let before = world.record_reads(); - let refused = failed( - create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body(&bogus, json!([operand(&remote, "needs-a-look")]), json!([])), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&refused, &["isn't a record on this knot"]); - assert_eq!( - world.record_reads(), - before, - "the knot rejects an unknown subject before fetching any def" - ); -} - -#[tokio::test] -async fn an_op_citing_an_erased_def_is_refused() { - let (world, repo) = issue_world().await; - let (issue, _) = opened(&world, &world.stranger, &repo, "kelp").await; - let (wontfix, cid) = wontfix(&world, &repo).await; - ok(delete_as( - &world, - &world.member, - &repo, - LABEL_DEF, - "wontfix".to_owned(), - json!({ "swapRecord": cid }), - ) - .await); - let refused = failed( - create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &issue, - json!([operand(wontfix.as_str(), "null")]), - json!([]), - ), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&refused, &["was deleted from this repository"]); - assert_eq!( - records_in(&world, &repo, LABEL_OP).await, - Vec::::new(), - "an op citing a deleted def is rejected without a record" - ); -} - -#[tokio::test] -async fn remote_def_with_unknown_fields_still_validates() { - let (world, repo) = issue_world().await; - let (issue, _) = opened(&world, &world.stranger, &repo, "kelp").await; - - let triage = format!("at://{}/{LABEL_DEF}/triage", world.stranger.did.as_str()); - let blocker = format!("at://{}/{LABEL_DEF}/blocker", world.stranger.did.as_str()); - world.publish_remote_def( - "triage", - json!({ - "name": "triage", - "scope": ["sh.tangled.repo.issue"], - "multiple": false, - "createdAt": "2026-09-01T00:00:00Z", - "futureField": {"note": "a lexicon revision this knot doesn't know"}, - "valueType": {"type": "string", "format": "any", "anotherFutureField": true} - }), - ); - world.publish_remote_def( - "blocker", - json!({ - "name": "blocker", - "scope": ["sh.tangled.repo.issue"], - "multiple": false, - "createdAt": "2026-09-01T00:00:00Z", - "valueType": {"type": "null", "format": "any"} - }), - ); - ok(create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &issue, - json!([operand(&triage, "needs-a-look"), operand(&blocker, "null")]), - json!([]), - ), - ) - .await); - let labels = labels_on(&world, &repo, &issue); - assert_eq!( - labels, - std::collections::BTreeMap::from([ - (triage, vec!["needs-a-look".to_owned()]), - (blocker, vec!["null".to_owned()]), - ]), - "a PDS def with fields this knot doesn't know still validates the op, the way \ - every atproto consumer tolerates a lexicon revision that the consumer hasn't met" - ); -} - -fn labels_on( - world: &World, - repo: &RepoDid, - subject: &AtUri, -) -> std::collections::BTreeMap> { - match world.state.index.social_labels_of( - repo, - &match knot_types::RecordAddress::parse_at_uri(subject) { - Ok((_, address)) => address, - Err(_) => panic!("The subject URI parses"), - }, - ) { - knot_index::Resolved::Ready(labels) => labels - .into_iter() - .map(|(def, values)| { - ( - def.to_string(), - values - .into_iter() - .map(|value| value.as_str().to_owned()) - .collect(), - ) - }) - .collect(), - knot_index::Resolved::Warming => panic!("The projection was warmed by the writes"), - } -} - -#[tokio::test] -async fn null_def_with_a_declared_format_is_refused_locally_and_remotely() { - let (world, repo) = issue_world().await; - let (issue, _) = opened(&world, &world.stranger, &repo, "kelp").await; - - let refused = failed( - create_as( - &world, - &world.member, - &repo, - LABEL_DEF, - Some("bent".to_owned()), - def_body( - "bent", - &["sh.tangled.repo.issue"], - json!({"type": "null", "format": "did"}), - ), - ) - .await, + ticket + .put( + &world.stranger, + &uri, + &v1, + TicketDraft::fresh(&repo).citing("ghost").record, + ) + .await, StatusCode::BAD_REQUEST, ); - message_contains(&refused, &["format"]); - - let remote = format!("at://{}/{LABEL_DEF}/bent", world.stranger.did.as_str()); - world.publish_remote_def( - "bent", - json!({ - "name": "bent", - "scope": ["sh.tangled.repo.issue"], - "multiple": false, - "createdAt": "2026-09-01T00:00:00Z", - "valueType": {"type": "null", "format": "did"} - }), - ); - let refused_remote = failed( - create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body(&issue, json!([operand(&remote, "null")]), json!([])), - ) - .await, - StatusCode::BAD_GATEWAY, - ); - message_contains(&refused_remote, &["doesn't parse"]); - assert_eq!( - records_in(&world, &repo, LABEL_OP).await, - Vec::::new(), - "the knot doesn't store an op citing a malformed def" - ); -} + message_contains(&refused, &["ghost", "isn't a label def on this repository"]); -#[tokio::test] -async fn erased_def_slug_stays_retired() { - let (world, repo) = issue_world().await; - let (_, cid) = wontfix(&world, &repo).await; + let (_, def_cid) = wontfix(&world, &repo).await; ok(delete_as( &world, &world.member, &repo, LABEL_DEF, "wontfix".to_owned(), - json!({ "swapRecord": cid }), - ) - .await); - let refused = failed( - create_as( - &world, - &world.member, - &repo, - LABEL_DEF, - Some("wontfix".to_owned()), - def_body("wontfix", &["sh.tangled.repo.issue"], null_type()), - ) - .await, - StatusCode::CONFLICT, - ); - message_contains(&refused, &["was erased at", "slug stays retired"]); - assert_eq!( - records_in(&world, &repo, LABEL_DEF).await, - Vec::::new(), - "the erased def doesn't serve, and the slug stays retired: one record address is \ - one object for life" - ); -} - -#[tokio::test] -async fn an_out_of_scope_operand_rejects_the_op() { - let (world, repo) = issue_world().await; - let (issue, cid) = opened(&world, &world.stranger, &repo, "kelp").await; - let comment = ok(create_as( - &world, - &world.member, - &repo, - COMMENT, - None, - comment_record(&issue, &cid, "kelp"), - ) - .await); - let (issue_only, _) = wontfix(&world, &repo).await; - let refused = failed( - create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &uri_of(&comment), - json!([operand(issue_only.as_str(), "null")]), - json!([]), - ), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&refused, &["outside this def's scope"]); -} - -#[tokio::test] -async fn a_def_flip_leaves_each_op_under_the_flag_in_force_at_write_time() { - let (world, repo) = issue_world().await; - let (second, _) = opened(&world, &world.stranger, &repo, "urchin").await; - let text_type = json!({"type": "string", "format": "any"}); - let (reviewer, cid) = define_label( - &world, - &repo, - "reviewer", - "reviewer", - &["sh.tangled.repo.issue"], - text_type.clone(), - true, - ) - .await; - ok(create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &second, - json!([ - operand(reviewer.as_str(), "conch"), - operand(reviewer.as_str(), "limpet") - ]), - json!([]), - ), + json!({ "swapRecord": def_cid }), ) .await); - assert_eq!( - labels_on(&world, &repo, &second), - std::collections::BTreeMap::from([( - reviewer.as_str().to_owned(), - vec!["conch".to_owned(), "limpet".to_owned()] - )]), - "under multiple the two adds accumulate" - ); - - ok(put_as( - &world, - &world.member, - &repo, - LABEL_DEF, - "reviewer".to_owned(), - json!({ "swapRecord": cid }), - def_body("reviewer", &["sh.tangled.repo.issue"], text_type), - ) - .await); - ok(create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &second, - json!([]), - json!([operand(reviewer.as_str(), "conch")]), - ), - ) - .await); - assert_eq!( - labels_on(&world, &repo, &second), - std::collections::BTreeMap::new(), - "a single delete written after the flip spells the standing first value and clears \ - limpet too, which was added under the multiple the def doesn't declare anymore" - ); -} - -#[tokio::test] -async fn a_def_on_another_repo_reads_from_that_repo_s_store() { - let (world, repo) = issue_world().await; - let (issue, _) = opened(&world, &world.stranger, &repo, "kelp").await; - let other = create_repo_helper(&world, "barnacle").await; - let (reviewer, _) = define_label( - &world, - &other, - "reviewer", - "reviewer", - &["sh.tangled.repo.issue"], - json!({"type": "string", "format": "any"}), - false, - ) - .await; - - let before = world.record_reads(); - ok(create_as( - &world, - &world.member, - &repo, - LABEL_OP, - None, - op_body( - &issue, - json!([operand(reviewer.as_str(), "nel")]), - json!([]), - ), - ) - .await); - assert_eq!( - world.record_reads(), - before, - "a def this knot hosts is read from that repo's store, without a getRecord \ - against the knot's public endpoint" + message_contains( + &ticket + .refused_create(TicketDraft::fresh(&repo).citing("wontfix").record) + .await, + &["wontfix", "whose label def was deleted"], ); } #[tokio::test] async fn a_stranger_probing_a_taken_slug_hears_ownership_first() { - let (world, repo) = issue_world().await; + let (world, repo) = ticket_world().await; wontfix(&world, &repo).await; let response = create_as( &world, @@ -7588,7 +7081,7 @@ async fn a_stranger_probing_a_taken_slug_hears_ownership_first() { &repo, LABEL_DEF, Some("wontfix".to_owned()), - def_body("wontfix", &["sh.tangled.repo.issue"], null_type()), + def_body("wontfix", Some("#79ffe1")), ) .await; assert_eq!(response.0, StatusCode::FORBIDDEN); @@ -7603,12 +7096,8 @@ mod pulls { use knot_git::{EntryKind, NewCommit, RefUpdate, StagedAction, StagedChange}; use knot_types::{Oid, RefName, UnixSeconds}; - const PULL: &str = "sh.tangled.repo.pull"; - const PULL_STATUS: &str = "sh.tangled.repo.pull.status"; + const PULL: &str = "org.tangled.review.patch"; const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; - const OPEN: &str = "sh.tangled.repo.pull.status.open"; - const CLOSED: &str = "sh.tangled.repo.pull.status.closed"; - const MERGED: &str = "sh.tangled.repo.pull.status.merged"; fn main_ref() -> RefName { RefName::new("refs/heads/main").unwrap() @@ -7704,93 +7193,44 @@ mod pulls { self.commit(parents, &staged, message) } - fn record(&self, title: &str, base: Oid, head: Oid) -> serde_json::Value { + async fn ticket( + &self, + actor: &Actor, + title: &str, + ) -> (AtUri, crate::out::CidString) { + let written = ok(Ticket::on(self.world, self.repo) + .open(actor, title, "a forest") + .await); + (uri_of(&written), cid_of(&written)) + } + + fn record(&self, ticket: &AtUri, base: Oid, head: Oid) -> serde_json::Value { json!({ "$type": PULL, - "target": { "repo": self.repo, "branch": "main" }, - "title": title, - "rounds": [], - "versions": [{ "base": base.to_hex(), "head": head.to_hex() }], + "ticket": rkey_of(ticket), + "base": { "oid": base.to_hex() }, + "head": { "oid": head.to_hex() }, "createdAt": "2026-09-11T00:00:00Z" }) } - fn with_versions(&self, title: &str, pairs: &[(Oid, Oid)]) -> serde_json::Value { - let mut record = self.record(title, pairs[0].0, pairs[0].1); - record["versions"] = json!( - pairs - .iter() - .map(|(base, head)| json!({ "base": base.to_hex(), "head": head.to_hex() })) - .collect::>() - ); - record - } - - fn stacked( - &self, - title: &str, - base: Oid, - head: Oid, - dependency: &AtUri, - ) -> serde_json::Value { - let mut record = self.record(title, base, head); - record["dependentOn"] = json!(dependency); - record - } - async fn open( &self, - actor: &Actor, - record: serde_json::Value, - ) -> (StatusCode, serde_json::Value) { - create_as(self.world, actor, self.repo, PULL, None, record).await - } - - async fn opened(&self, actor: &Actor, record: serde_json::Value) -> AtUri { - uri_of(&ok(self.open(actor, record).await)) - } - - async fn refused_open(&self, record: serde_json::Value) -> serde_json::Value { - failed( - self.open(&self.world.stranger, record).await, - StatusCode::BAD_REQUEST, - ) - } - - async fn status( - &self, - actor: &Actor, - pull: &AtUri, - status: &str, - ) -> (StatusCode, serde_json::Value) { - create_as( - self.world, - actor, - self.repo, - PULL_STATUS, - None, - json!({ - "$type": PULL_STATUS, - "pull": pull, - "status": status, - "createdAt": "2026-09-11T00:00:00Z" - }), - ) - .await + actor: &Actor, + record: serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + create_as(self.world, actor, self.repo, PULL, None, record).await } - async fn set_status(&self, actor: &Actor, pull: &AtUri, status: &str) { - ok(self.status(actor, pull, status).await); + async fn opened(&self, actor: &Actor, record: serde_json::Value) -> AtUri { + uri_of(&ok(self.open(actor, record).await)) } - async fn refused_status( - &self, - actor: &Actor, - pull: &AtUri, - status: &str, - want: StatusCode, - ) -> serde_json::Value { - failed(self.status(actor, pull, status).await, want) + async fn refused_open(&self, record: serde_json::Value) -> serde_json::Value { + failed( + self.open(&self.world.stranger, record).await, + StatusCode::BAD_REQUEST, + ) } async fn merge(&self, actor: &Actor, rkey: &str) -> (StatusCode, serde_json::Value) { @@ -7816,109 +7256,47 @@ mod pulls { ) } - async fn refused_put( - &self, - rkey: String, - swap: serde_json::Value, - record: serde_json::Value, - ) -> serde_json::Value { - failed( - put_as( - self.world, - &self.world.stranger, - self.repo, - PULL, - rkey, - swap, - record, - ) - .await, - StatusCode::BAD_REQUEST, - ) - } - - async fn erase( - &self, - actor: &Actor, - collection: &str, - uri: &AtUri, - ) -> (StatusCode, serde_json::Value) { - let cid = self.at(uri).await["cid"].clone(); - delete_as( - self.world, - actor, - self.repo, - collection, - rkey_of(uri), - json!({ "swapRecord": cid }), - ) - .await - } - async fn at(&self, uri: &AtUri) -> serde_json::Value { ok(record_at(self.world, self.repo, uri, None).await) } - - async fn standing_status(&self) -> String { - let mut newest = None; - for uri in records_in(self.world, self.repo, PULL_STATUS).await { - let at = AtUri::new_owned(uri).unwrap(); - let rkey = at.rkey().unwrap().as_str().to_owned(); - let record = ok(record_at(self.world, self.repo, &at, None).await); - if newest - .as_ref() - .is_none_or(|(kept, _): &(String, serde_json::Value)| rkey > *kept) - { - newest = Some((rkey, record)); - } - } - newest - .map(|(_, record)| record["value"]["status"].as_str().unwrap().to_owned()) - .expect("a pull with a status transition has a status record") - } } - async fn kelp_pull() -> ( + async fn kelp_patch() -> ( World, RepoDid, Oid, Oid, AtUri, + AtUri, crate::out::CidString, ) { - let (world, repo) = issue_world().await; + let (world, repo) = ticket_world().await; let pulls = Pulls::on(&world, &repo); let base = pulls.commit_with(&[], &[("kelp.txt", "forest")], "base"); let head = pulls.commit_with(&[base], &[("kelp.txt", "reef")], "head"); pulls.set_main(base); + let (ticket, _) = pulls.ticket(&world.stranger, "kelp").await; let written = ok(pulls - .open(&world.stranger, pulls.record("kelp", base, head)) + .open(&world.stranger, pulls.record(&ticket, base, head)) .await); - (world, repo, base, head, uri_of(&written), cid_of(&written)) - } - - async fn stacked_pulls() -> (World, RepoDid, Oid, Oid, AtUri, AtUri) { - let (world, repo) = issue_world().await; - let pulls = Pulls::on(&world, &repo); - let base = pulls.commit_with(&[], &[("kelp.txt", "forest")], "base"); - let head = pulls.commit_with(&[base], &[("kelp.txt", "reef")], "head"); - pulls.set_main(base); - let below = pulls - .opened(&world.stranger, pulls.record("below", base, head)) - .await; - let above = pulls - .opened(&world.stranger, pulls.stacked("above", base, head, &below)) - .await; - (world, repo, base, head, below, above) + ( + world, + repo, + base, + head, + ticket, + uri_of(&written), + cid_of(&written), + ) } #[tokio::test] - async fn opened_pull_writes_keep_refs_for_its_versions() { - let (world, repo, base, head, uri, _) = kelp_pull().await; + async fn opened_patch_writes_keep_refs_for_its_commits() { + let (world, repo, base, head, _ticket, uri, _) = kelp_patch().await; let pulls = Pulls::on(&world, &repo); let record = pulls.at(&uri).await; - assert_eq!(record["value"]["versions"][0]["base"], base.to_hex()); - assert_eq!(record["value"]["versions"][0]["head"], head.to_hex()); + assert_eq!(record["value"]["base"]["oid"], base.to_hex()); + assert_eq!(record["value"]["head"]["oid"], head.to_hex()); assert_eq!( record["value"]["x-tngl-editor"], world.stranger.did.as_str() @@ -7932,212 +7310,142 @@ mod pulls { assert_eq!( pulls.git().find_ref(&keep).unwrap(), Some(pinned), - "an accepted version's commits stay pinned by a keep ref" + "an accepted patch's commits stay pinned by a keep ref" ); } } #[tokio::test] - async fn opening_rejects_ghost_heads_crossed_ranges_missing_branches_and_floods() { - let (world, repo) = issue_world().await; + async fn opening_rejects_ghost_heads_crossed_ranges_and_non_tickets() { + let (world, repo) = ticket_world().await; let pulls = Pulls::on(&world, &repo); let base = pulls.commit_with(&[], &[("kelp.txt", "forest")], "base"); pulls.set_main(base); + let (ticket, _) = pulls.ticket(&world.stranger, "kelp").await; let ghost = Oid::from_hex(&"f".repeat(40)).unwrap(); - let refused = pulls.refused_open(pulls.record("kelp", base, ghost)).await; + let refused = pulls.refused_open(pulls.record(&ticket, base, ghost)).await; message_contains( &refused, &["head", &ghost.to_hex(), "isn't a commit in this repository"], ); let right = pulls.commit_with(&[], &[("rock.txt", "grey")], "right"); - let refused = pulls - .refused_open(pulls.record("crossed", base, right)) - .await; + let refused = pulls.refused_open(pulls.record(&ticket, base, right)).await; message_contains( &refused, &["isn't an ancestor of its head", &right.to_hex()], ); - let mut ghost_branch = pulls.record("kelp", base, base); - ghost_branch["target"]["branch"] = json!("ghost"); - let refused = pulls.refused_open(ghost_branch).await; - message_contains(&refused, &["opens onto a branch", "ghost isn't one"]); + let missing = AtUri::new_owned(format!("at://{repo}/{TICKET}/3lubrptx5zzzz")).unwrap(); + let refused = pulls.refused_open(pulls.record(&missing, base, base)).await; + message_contains(&refused, &["isn't a record on this knot"]); - let mut flooded = pulls.record("flood", base, base); - flooded["versions"] = json!( - (0..=knot_record::pull::MAX_VERSIONS) - .map(|at| json!({ "base": base.to_hex(), "head": format!("{:040}", at) })) - .collect::>() - ); - let refused = pulls.refused_open(flooded).await; + let mut with_blobs = pulls.record(&ticket, base, base); + with_blobs["blobs"] = json!([]); message_contains( - &refused, - &[ - &format!("stops at {} versions", knot_record::pull::MAX_VERSIONS), - "sends 257", - ], + &pulls.refused_open(with_blobs).await, + &["unknown field", "blobs"], ); } #[tokio::test] - async fn versions_append_only_and_a_repeated_put_doesnt_append() { - let (world, repo, base, head, uri, cid) = kelp_pull().await; + async fn a_patch_is_keyed_by_its_own_record_and_never_edited() { + let (world, repo, base, head, ticket, uri, cid) = kelp_patch().await; let pulls = Pulls::on(&world, &repo); - let again = pulls.commit_with(&[head], &[("kelp.txt", "atoll")], "again"); - let appended = ok(put_as( - &world, - &world.stranger, - &repo, - PULL, - rkey_of(&uri), - json!({ "swapRecord": cid }), - pulls.with_versions("kelp", &[(base, head), (base, again)]), - ) - .await); assert_eq!( - pulls.at(&uri).await["value"]["versions"] - .as_array() - .unwrap() - .len(), - 2, - "{appended}" + rkey_of(&uri), + cid.to_string(), + "the patch's record key is the CID of its own record" ); - let rewritten = pulls - .refused_put( - rkey_of(&uri), - json!({ "swapRecord": cid_of(&appended) }), - pulls.record("kelp", again, again), - ) - .await; - message_contains(&rewritten, &["only append"]); - - let repeated = pulls - .refused_put( + let again = pulls.commit_with(&[head], &[("kelp.txt", "atoll")], "again"); + let refused = failed( + put_as( + &world, + &world.stranger, + &repo, + PULL, rkey_of(&uri), - json!({ "swapRecord": cid_of(&appended) }), - pulls.with_versions("kelp", &[(base, head), (base, again), (base, again)]), + json!({ "swapRecord": cid }), + pulls.record(&ticket, base, again), ) - .await; - message_contains( - &repeated, - &["repeats a version", "a resubmission has a new head"], + .await, + StatusCode::BAD_REQUEST, ); + message_contains(&refused, &["a patch is immutable"]); - ok(put_as( - &world, - &world.stranger, - &repo, - PULL, - rkey_of(&uri), - json!({ "swapRecord": cid_of(&appended) }), - pulls.with_versions("kelp", &[(base, head), (base, again)]), - ) - .await); + let resubmitted = ok(pulls + .open(&world.stranger, pulls.record(&ticket, base, again)) + .await); + let resubmitted_uri = uri_of(&resubmitted); + let resubmitted_cid = cid_of(&resubmitted); + assert_ne!( + resubmitted_uri, uri, + "resubmission is a new patch record, not an edit" + ); assert_eq!( - pulls.at(&uri).await["value"]["versions"] - .as_array() - .unwrap() - .len(), - 2, - "an idempotent re-put doesn't append a version" + rkey_of(&resubmitted_uri), + resubmitted_cid.to_string(), + "the new patch is keyed by its own CID too" ); - } - - #[tokio::test] - async fn a_pull_allows_one_dependent_until_it_goes() { - let (world, repo, base, head, below, above) = stacked_pulls().await; - let pulls = Pulls::on(&world, &repo); - let dag = pulls - .refused_open(pulls.stacked("third", base, head, &below)) - .await; - message_contains( - &dag, - &["another pull already depends on", "only one dependent"], + assert_eq!( + pulls.at(&resubmitted_uri).await["value"]["head"]["oid"], + again.to_hex() ); - ok(pulls.erase(&world.stranger, PULL, &above).await); - ok(pulls - .open( - &world.stranger, - pulls.stacked("restacked", base, head, &below), - ) - .await); - } - - #[tokio::test] - async fn status_writes_mirror_issue_states_and_a_closed_pull_isnt_merged() { - let (world, repo, base, _, uri, _) = kelp_pull().await; - let pulls = Pulls::on(&world, &repo); - let refused = pulls - .refused_status(&world.stranger, &uri, MERGED, StatusCode::BAD_REQUEST) - .await; - message_contains(&refused, &["comes from the merge endpoint"]); - - pulls.set_status(&world.stranger, &uri, CLOSED).await; - let repeat = pulls - .refused_status(&world.stranger, &uri, CLOSED, StatusCode::CONFLICT) - .await; - message_contains(&repeat, &["already"]); - - let meddler = pulls - .refused_status(&world.passerby, &uri, OPEN, StatusCode::FORBIDDEN) - .await; - message_contains(&meddler, &["author or a maintainer"]); - - let shut = pulls.refused_merge(&rkey_of(&uri)).await; - message_contains(&shut, &["is closed, and a closed pull isn't merged"]); - assert_eq!(pulls.tip(), base); + let repeated = ok(pulls + .open(&world.stranger, pulls.record(&ticket, base, again)) + .await); assert_eq!( - set_policy_as( - &world, - &world.member, - &repo, - ContributionPolicy::Collaborators - ) - .await, - StatusCode::OK - ); - let shut_out = pulls - .refused_status(&world.stranger, &uri, OPEN, StatusCode::FORBIDDEN) - .await; - message_contains( - &shut_out, - &["may not open", "contribution policy is collaborators"], + uri_of(&repeated), + resubmitted_uri, + "creating the same patch twice serves the one record both times" ); } #[tokio::test] - async fn merged_pull_rejects_further_merges_and_transitions() { - let (world, repo, _base, head, uri, _) = kelp_pull().await; + async fn merged_patch_completes_the_ticket_and_moves_the_branch() { + let (world, repo, _base, head, ticket, uri, _) = kelp_patch().await; let pulls = Pulls::on(&world, &repo); + let tickets = Ticket::on(&world, &repo); let merged = pulls.merged(&rkey_of(&uri)).await; assert_eq!(merged["tip"], head.to_hex()); assert_eq!(pulls.tip(), head); - assert_eq!(pulls.standing_status().await, MERGED); - - for token in [OPEN, CLOSED] { - let refused = pulls - .refused_status(&world.stranger, &uri, token, StatusCode::CONFLICT) - .await; - message_contains( - &refused, - &["is merged, and a merged pull doesn't transition"], - ); - } - let again = pulls.refused_merge(&rkey_of(&uri)).await; - message_contains(&again, &["already merged"]); + let ticket_record = pulls.at(&ticket).await; + assert_eq!(ticket_record["value"]["state"], "completed"); - pulls.drop_main(head); let again = pulls.refused_merge(&rkey_of(&uri)).await; message_contains(&again, &["already merged"]); + let from = cid_of(&pulls.at(&ticket).await); + let reopened = ok(tickets + .set_state(&world.stranger, &ticket, &from, TicketState::Closed) + .await); + assert_ne!( + cid_of(&reopened), + from, + "completed isn't terminal; the merged ticket still takes a state write" + ); + assert_eq!(pulls.at(&ticket).await["value"]["state"], CLOSED); + } + + #[tokio::test] + async fn merge_rejects_a_closed_ticket() { + let (world, repo, base, _head, ticket, uri, _) = kelp_patch().await; + let pulls = Pulls::on(&world, &repo); + let tickets = Ticket::on(&world, &repo); + let from = cid_of(&pulls.at(&ticket).await); + ok(tickets + .set_state(&world.stranger, &ticket, &from, TicketState::Closed) + .await); + let shut = pulls.refused_merge(&rkey_of(&uri)).await; + message_contains(&shut, &["is closed, and a closed pull isn't merged"]); + assert_eq!(pulls.tip(), base); } #[tokio::test] - async fn diverged_pull_replays_and_conflicts_show_the_file() { - let (world, repo) = issue_world().await; + async fn diverged_patch_replays_and_conflicts_show_the_file() { + let (world, repo) = ticket_world().await; let pulls = Pulls::on(&world, &repo); let base = pulls.commit_with(&[], &[("kelp.txt", "forest"), ("rock.txt", "grey")], "base"); let side = pulls.commit_with(&[base], &[("kelp.txt", "tide")], "side"); @@ -8145,14 +7453,30 @@ mod pulls { let touching = pulls.commit_with(&[base], &[("kelp.txt", "reef")], "touch"); let clean = pulls.commit_with(&[base], &[("sand.txt", "warm")], "clean"); + let (ticket, _) = pulls.ticket(&world.stranger, "clash").await; let clash = pulls - .opened(&world.stranger, pulls.record("clash", base, touching)) + .opened(&world.stranger, pulls.record(&ticket, base, touching)) .await; let refused = pulls.refused_merge(&rkey_of(&clash)).await; message_contains(&refused, &["Merge failed due to conflicts", "kelp.txt"]); + assert_eq!( + pulls.at(&ticket).await["value"]["state"], + serde_json::json!("open"), + "a conflicted replay leaves the ticket open, so the merge can be retried" + ); + pulls.move_main(side, base); + let merged = pulls.merged(&rkey_of(&clash)).await; + assert_ne!(merged["tip"], serde_json::json!("")); + assert_eq!( + pulls.at(&ticket).await["value"]["state"], + serde_json::json!("completed"), + "the retried merge completes the ticket once the conflict is gone" + ); + + let (ticket2, _) = pulls.ticket(&world.stranger, "neat").await; let neat = pulls - .opened(&world.stranger, pulls.record("neat", base, clean)) + .opened(&world.stranger, pulls.record(&ticket2, base, clean)) .await; let merged = pulls.merged(&rkey_of(&neat)).await; let tip = pulls.tip(); @@ -8162,52 +7486,9 @@ mod pulls { assert!(pulls.has(tip, "rock.txt")); } - #[tokio::test] - async fn a_stack_merges_bottom_first_and_rejects_a_closed_dependency() { - let (world, repo) = issue_world().await; - let pulls = Pulls::on(&world, &repo); - let base = pulls.commit_with(&[], &[("kelp.txt", "forest")], "base"); - let middle = pulls.commit_with(&[base], &[("kelp.txt", "reef")], "middle"); - let top = pulls.commit_with(&[middle], &[("kelp.txt", "atoll")], "top"); - pulls.set_main(base); - let below = pulls - .opened(&world.stranger, pulls.record("below", base, middle)) - .await; - let above = pulls - .opened(&world.stranger, pulls.stacked("above", middle, top, &below)) - .await; - - let merged = pulls.merged(&rkey_of(&above)).await; - assert_eq!(pulls.tip(), top); - assert_eq!( - merged["merged"].as_array().unwrap().len(), - 2, - "the dependency merges first, in the same call" - ); - assert_eq!(pulls.standing_status().await, MERGED); - - let fresh_base = pulls.commit_with(&[top], &[("new.txt", "y")], "fresh"); - let fresh_head = pulls.commit_with(&[fresh_base], &[("new.txt", "z")], "fh"); - let shut = pulls - .opened( - &world.stranger, - pulls.record("shut", fresh_base, fresh_head), - ) - .await; - pulls.set_status(&world.stranger, &shut, CLOSED).await; - let late = pulls - .opened( - &world.stranger, - pulls.stacked("late", fresh_base, fresh_head, &shut), - ) - .await; - let refused = pulls.refused_merge(&rkey_of(&late)).await; - message_contains(&refused, &["closed", "dependency"]); - } - #[tokio::test] async fn boot_mends_a_rolled_back_move_and_leaves_an_advanced_branch_alone() { - let (world, repo, base, head, uri, _) = kelp_pull().await; + let (world, repo, base, head, _ticket, uri, _) = kelp_patch().await; let pulls = Pulls::on(&world, &repo); pulls.merged(&rkey_of(&uri)).await; assert_eq!(pulls.tip(), head); @@ -8216,7 +7497,7 @@ mod pulls { let finished = crate::social::seal_pending_merges(&world.state).await; assert_eq!( finished, 1, - "the boot pass finishes the branch move the merged status recorded" + "the boot pass finishes the branch move the completed ticket recorded" ); assert_eq!(pulls.tip(), head); @@ -8230,77 +7511,9 @@ mod pulls { assert_eq!(pulls.tip(), onward); } - #[tokio::test] - async fn pull_comments_require_a_round_index() { - let (world, repo, _, _, uri, cid) = kelp_pull().await; - let with_round = - |subject: &AtUri, cid: &crate::out::CidString, text: &str, round: u32| { - let mut record = comment_record(subject, cid, text); - record["pullRoundIdx"] = json!(round); - record - }; - let bare = failed( - create_as( - &world, - &world.stranger, - &repo, - COMMENT, - None, - comment_record(&uri, &cid, "which round?"), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&bare, &["pullRoundIdx is required"]); - - let past = failed( - create_as( - &world, - &world.stranger, - &repo, - COMMENT, - None, - with_round(&uri, &cid, "later", 1), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains( - &past, - &["1 submission rounds", "pullRoundIdx 1 is past them"], - ); - - let spoken = ok(create_as( - &world, - &world.stranger, - &repo, - COMMENT, - None, - with_round(&uri, &cid, "this one", 0), - ) - .await); - let comment = ok(record_at(&world, &repo, &uri_of(&spoken), None).await); - assert_eq!(comment["value"]["pullRoundIdx"], serde_json::json!(0)); - - let (issue_uri, issue_cid) = opened(&world, &world.stranger, &repo, "an issue").await; - let stray = failed( - create_as( - &world, - &world.stranger, - &repo, - COMMENT, - None, - with_round(&issue_uri, &issue_cid, "stray", 0), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&stray, &["this subject is an issue"]); - } - #[tokio::test] async fn the_pulls_author_cant_merge_it() { - let (world, repo, _, _, uri, _) = kelp_pull().await; + let (world, repo, _, _, _ticket, uri, _) = kelp_patch().await; let pulls = Pulls::on(&world, &repo); let rkey = rkey_of(&uri); for outsider in [&world.stranger, &world.passerby] { @@ -8311,7 +7524,7 @@ mod pulls { #[tokio::test] async fn the_replay_stages_a_renamed_file_at_its_new_path() { - let (world, repo) = issue_world().await; + let (world, repo) = ticket_world().await; let pulls = Pulls::on(&world, &repo); let base = pulls.commit_with(&[], &[("kelp.txt", "forest"), ("rock.txt", "grey")], "base"); let side = pulls.commit_with(&[base], &[("rock.txt", "warm")], "side"); @@ -8333,8 +7546,9 @@ mod pulls { ], "rename kelp", ); + let (ticket, _) = pulls.ticket(&world.stranger, "kelp").await; let uri = pulls - .opened(&world.stranger, pulls.record("kelp", base, moved)) + .opened(&world.stranger, pulls.record(&ticket, base, moved)) .await; pulls.merged(&rkey_of(&uri)).await; @@ -8343,7 +7557,7 @@ mod pulls { let expected = git .entry_at(moved, &knot_types::RepoPath::new("reef.txt").unwrap()) .unwrap() - .expect("the renamed file exists at its new path in the pull's own commit"); + .expect("the renamed file exists at its new path in the patch's own commit"); assert_eq!( git.entry_at(tip, &knot_types::RepoPath::new("reef.txt").unwrap()) .unwrap() @@ -8362,69 +7576,9 @@ mod pulls { ); } - #[tokio::test] - async fn pull_status_records_reject_edits_and_erasure() { - let (world, repo, _, _, uri, _) = kelp_pull().await; - let pulls = Pulls::on(&world, &repo); - pulls.set_status(&world.stranger, &uri, CLOSED).await; - - let standing = records_in(&world, &repo, PULL_STATUS) - .await - .into_iter() - .next() - .expect("the closed transition has a record"); - let at = AtUri::new_owned(standing).unwrap(); - let record = pulls.at(&at).await; - let edited = failed( - put_as( - &world, - &world.stranger, - &repo, - PULL_STATUS, - rkey_of(&at), - json!({ "swapRecord": record["cid"].clone() }), - json!({ - "$type": PULL_STATUS, - "pull": uri, - "status": OPEN, - "createdAt": "2026-09-11T00:00:00Z" - }), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&edited, &["can't edit a pull status"]); - let erased = failed( - pulls.erase(&world.stranger, PULL_STATUS, &at).await, - StatusCode::BAD_REQUEST, - ); - message_contains(&erased, &["won't erase a pull status"]); - assert_eq!( - pulls.standing_status().await, - CLOSED, - "the rejected edit and erase left the standing transition alone" - ); - } - - #[tokio::test] - async fn erasing_the_bottom_of_a_stack_is_rejected_until_the_top_goes() { - let (world, repo, _, _, below, above) = stacked_pulls().await; - let pulls = Pulls::on(&world, &repo); - let stranded = failed( - pulls.erase(&world.stranger, PULL, &below).await, - StatusCode::BAD_REQUEST, - ); - message_contains( - &stranded, - &["has a pull depending on it", "erase the dependent first"], - ); - ok(pulls.erase(&world.stranger, PULL, &above).await); - ok(pulls.erase(&world.stranger, PULL, &below).await); - } - #[tokio::test] async fn a_merge_commit_replays_after_its_parents() { - let (world, repo) = issue_world().await; + let (world, repo) = ticket_world().await; let pulls = Pulls::on(&world, &repo); let base = pulls.commit_with(&[], &[("kelp.txt", "forest"), ("rock.txt", "grey")], "base"); let left = pulls.commit_with(&[base], &[("a.txt", "left")], "left"); @@ -8436,8 +7590,9 @@ mod pulls { ); let side = pulls.commit_with(&[base], &[("rock.txt", "warm")], "side"); pulls.set_main(side); + let (ticket, _) = pulls.ticket(&world.stranger, "join").await; let uri = pulls - .opened(&world.stranger, pulls.record("join", base, joined)) + .opened(&world.stranger, pulls.record(&ticket, base, joined)) .await; pulls.merged(&rkey_of(&uri)).await; let tip = pulls.tip(); @@ -8452,7 +7607,7 @@ mod pulls { #[tokio::test] async fn a_merge_into_a_deleted_branch_identifies_the_branch() { - let (world, repo, base, _head, uri, _) = kelp_pull().await; + let (world, repo, base, _head, _ticket, uri, _) = kelp_patch().await; let pulls = Pulls::on(&world, &repo); pulls.drop_main(base); let refused = pulls.refused_merge(&rkey_of(&uri)).await; @@ -8461,4 +7616,79 @@ mod pulls { &["target branch refs/heads/main was deleted before the merge began"], ); } + + #[tokio::test] + async fn a_ticket_targets_a_branch_and_the_merge_moves_it() { + let (world, repo) = ticket_world().await; + let pulls = Pulls::on(&world, &repo); + let base = pulls.commit_with(&[], &[("kelp.txt", "forest")], "base"); + let head = pulls.commit_with(&[base], &[("kelp.txt", "reef")], "head"); + pulls.set_main(base); + let reef = RefName::new("refs/heads/reef").unwrap(); + pulls + .git() + .update_ref(&RefUpdate::Create { + name: reef.clone(), + new: base, + }) + .unwrap(); + + let tickets = Ticket::on(&world, &repo); + let ticket = uri_of(&ok(tickets + .create(&world.stranger, TicketDraft::fresh(&repo).on("reef").record) + .await)); + assert_eq!( + ok(record_at(&world, &repo, &ticket, None).await)["value"]["targetBranch"], + "reef" + ); + + let patch_uri = uri_of(&ok(pulls + .open(&world.stranger, pulls.record(&ticket, base, head)) + .await)); + pulls.merged(&rkey_of(&patch_uri)).await; + assert_eq!( + pulls.git().find_ref(&reef).unwrap(), + Some(head), + "the merge moves the ticket's targetBranch" + ); + assert_eq!( + pulls.tip(), + base, + "and the default branch stays where it was" + ); + let completed = ok(record_at(&world, &repo, &ticket, None).await); + assert_eq!(completed["value"]["state"], "completed"); + assert_eq!( + completed["value"]["targetBranch"], "reef", + "completing the ticket keeps its targetBranch" + ); + + for (draft, fragments) in [ + ( + TicketDraft::fresh(&repo).on("refs/heads/reef").record, + vec!["without the refs/ prefix"], + ), + ( + TicketDraft::fresh(&repo).on("ghost").record, + vec!["opens onto a branch", "ghost isn't one"], + ), + ] { + message_contains(&tickets.refused_create(draft).await, &fragments); + } + let retargeted = failed( + tickets + .put( + &world.stranger, + &ticket, + &cid_of(&completed), + TicketDraft::fresh(&repo).on("no-such-branch").record, + ) + .await, + StatusCode::BAD_REQUEST, + ); + message_contains( + &retargeted, + &["targetBranch opens onto a branch", "no-such-branch"], + ); + } } -- 2.51.2