Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032//! `issue create`, `close`/`reopen`, `edit`, `comment` and `list`, driven as//! sequences.//!//! Three things here cannot be reached by a unit test, and each has already//! cost somebody something in the pull-request half of this tree.//!//! **Which collection a comment goes in.** `sh.tangled.repo.issue.comment` is//! vendored in this repo, is named in the lexicon manifest and looks entirely//! current; it is deprecated, and a comment written there is accepted by the//! PDS, federates, and is invisible on tangled.org for ever. Nothing about//! that failure is observable from inside the process — only "what did atgc//! send, to which collection" catches it, which is what this file asks.//!//! **Which PDS a state record lands in.** Closing somebody else's issue on//! your own repo writes into *your* repository, not theirs, and a version//! that wrote into theirs would fail at a real PDS and pass every test of//! either half alone.//!//! **Whether a read-modify-write keeps what it did not mean to touch.**//! `issue edit` replaces two fields of a record and puts the whole thing//! back; the bug that shape invites is losing the rest.//!//! See `tests/support/mod.rs` for the environment and the argument for it.
mod support;
use support::{ALICE, BOB, FEED_COMMENT_NSID, ISSUE_NSID, ISSUE_STATE_NSID, REPO_DID, Scenario};
/// A repo Alice does *not* own, for the filter tests. Distinct from/// [`REPO_DID`] and from any account DID, the way a real repo DID is.const OTHER_REPO: &str = "did:plc:oooooooooooooooooooooooo";
/// File an issue and hand back its record key.////// A body every time, because tangled.org requires one — see/// [`an_issue_with_no_body_is_refused_before_anything_leaves_the_machine`].fn open_issue(world: &Scenario, title: &str) -> String { let created = world .run(&[ "issue", "create", "--title", title, "--body", "what happened", "--json", ]) .success() .json(); created["rkey"] .as_str() .expect("issue create --json carries the record key it minted") .to_string()}
/// The smallest thing `scan` will accept as a PNG: the eight-byte signature/// is what it sniffs, and nothing downstream decodes the pixels.const PNG: &[u8] = b"\x89PNG\r\n\x1a\n and then some bytes";/// How many `listRecords` calls went out for one collection.////// The whole assertion of/// [`a_listing_walks_the_state_collection_once_however_many_rows_it_has`]:/// the state walk is a page of records either way, and what changed is how/// many times it is fetched.fn state_walks(world: &Scenario) -> usize { world.with(|w| { w.calls_to("com.atproto.repo.listRecords") .iter() .filter(|c| c.params.get("collection").map(String::as_str) == Some(ISSUE_STATE_NSID)) .count() })}
/// The one issue record an account holds, with its key.fn only_issue(world: &Scenario, did: &str) -> (String, serde_json::Value) { let mut issues = world.issues(did); assert_eq!(issues.len(), 1, "expected exactly one issue: {issues:#?}"); issues.pop().expect("one issue")}
// ---------------------------------------------------------------------------// create// ---------------------------------------------------------------------------
/// A local image in an issue body is uploaded and the path swapped for the/// `blob+at://` URI Tangled resolves.////// Three things at once, and each is invisible from inside the process. The/// blob has to *exist* on the PDS — a record naming a CID nothing uploaded/// renders as a broken image. The body has to carry the URI rather than the/// path, since a relative path on a Tangled page is a broken link and/// nothing else. And the record's `blobs` array has to list it, or the PDS/// is free to collect the blob and the issue breaks later with nothing/// having been deleted.#[test]fn a_local_image_in_an_issue_body_is_uploaded_and_the_path_rewritten() { let world = Scenario::new("issue-create-image"); world.checkout.write("shot.png", PNG);
let created = world .run(&[ "issue", "create", "--title", "it looks like this", "--body", "before:\n\n\n", "--json", ]) .success() .json();
assert_eq!(created["images"][0]["path"].as_str(), Some("shot.png")); assert_eq!(created["images"][0]["mime"].as_str(), Some("image/png"));
let (_, value) = only_issue(&world, ALICE); let body = value["body"].as_str().expect("a body"); assert!( !body.contains("shot.png"), "the local path survived: {body}" ); let uri = body .split_once("blob+at://") .map(|(_, rest)| rest.split(')').next().unwrap_or_default().to_string()) .expect("the body carries a blob+at:// URI"); let (did, cid) = uri.split_once('/').expect("blob+at://<did>/<cid>"); assert_eq!(did, ALICE, "the blob is the author's, not the repo's");
// Listed on the record, and really on the PDS. let listed = value["blobs"][0]["ref"]["$link"].as_str(); assert_eq!(listed, Some(cid), "{value:#}"); assert_eq!( world.blob(cid), PNG, "the record names a CID the PDS does not hold" );}
/// A dry run says what it would upload and uploads nothing.////// The order is the point: the scan happens before the network, so a preview/// names every file and refuses a bad one, while the upload waits for a real/// run. A blob no record references is one the PDS may collect, and a dry/// run must not leave one.#[test]fn an_issue_dry_run_names_its_images_and_uploads_none() { let world = Scenario::new("issue-create-image-dry-run"); world.checkout.write("shot.png", PNG);
let report = world .run(&[ "issue", "create", "--title", "preview", "--body", "", "--dry-run", "--json", ]) .success() .json();
assert_eq!(report["dry_run"], true, "{report:#}"); assert_eq!(report["images"][0]["path"].as_str(), Some("shot.png")); assert!( world.with(|w| w.blobs.is_empty()), "a dry run uploaded a blob" ); assert!(world.issues(ALICE).is_empty(), "a dry run wrote a record");}
/// A body naming a file that is not there is refused before anything leaves/// the machine, by name.#[test]fn an_issue_body_naming_a_missing_image_is_refused_locally() { let world = Scenario::new("issue-create-image-missing");
world .run(&[ "issue", "create", "--title", "broken", "--body", "", ]) .refused("nowhere.png"); assert!(world.issues(ALICE).is_empty()); assert!(world.with(|w| w.blobs.is_empty()));}
/// What `issue create` puts on the wire: one record, in the reporter's own/// PDS, naming the repo by the repo's own DID rather than by its owner's.////// The DID is the assertion that matters. A repo's DID and its owner's are/// both well-formed DIDs, so writing the wrong one produces a record the PDS/// accepts and the appview files against a repo that does not exist.#[test]fn create_writes_one_issue_record_naming_the_repo_by_its_own_did() { let world = Scenario::new("issue-create");
let created = world .run(&[ "issue", "create", "--title", "pr list is slow", "--body", "on a big repo", "--json", ]) .success() .json();
let (rkey, value) = only_issue(&world, ALICE); assert_eq!(created["rkey"].as_str(), Some(rkey.as_str())); assert_eq!( created["uri"].as_str(), Some(format!("at://{ALICE}/{ISSUE_NSID}/{rkey}").as_str()) ); assert_eq!(value["$type"].as_str(), Some(ISSUE_NSID)); assert_eq!(value["repo"].as_str(), Some(REPO_DID)); assert_eq!(value["title"].as_str(), Some("pr list is slow")); assert_eq!(value["body"].as_str(), Some("on a big repo")); assert!( value["createdAt"].as_str().is_some_and(|s| !s.is_empty()), "an issue record needs a timestamp: {value:#}" ); // Nothing atgc does not set: `mentions`, `references` and `blobs` are the // web UI's, and a record carrying empty arrays for them would be a claim // this command has no basis for. for absent in ["mentions", "references", "blobs"] { assert!(value.get(absent).is_none(), "{absent} in {value:#}"); }}
/// An issue is written into its reporter's PDS with that reporter's/// credentials, whoever is active and whoever owns the repo.#[test]fn an_issue_is_written_by_and_into_the_selected_account() { let world = Scenario::new("issue-create-identity");
world .run_as( BOB, &[ "issue", "create", "--title", "bob's issue", "--body", "it broke", ], ) .success();
assert!(world.issues(ALICE).is_empty(), "it landed in the wrong PDS"); assert_eq!(world.issues(BOB).len(), 1); let writes = world.with(|w| { w.journal .iter() .filter(|c| c.actor.is_some()) .map(|c| (c.label(), c.actor.clone().unwrap())) .collect::<Vec<_>>() }); assert!(!writes.is_empty(), "nothing authenticated was sent"); for (label, actor) in &writes { assert_eq!(actor, BOB, "{label} went out as the wrong account"); }}
/// A dry run describes the issue and stops. Its `uri` and `rkey` are null/// rather than a guess: the record key is the PDS's to mint, and inventing/// one is the single most misleading thing this output could do.#[test]fn a_dry_run_creates_nothing_and_mints_no_identifiers() { let world = Scenario::new("issue-create-dry");
let planned = world .run(&[ "issue", "create", "--title", "not sent", "--body", "nor this", "--dry-run", "--json", ]) .success() .json();
assert_eq!(planned["dry_run"], true); assert!(planned["uri"].is_null(), "{planned:#}"); assert!(planned["rkey"].is_null(), "{planned:#}"); // Everything knowable without a write is still filled in. assert_eq!(planned["title"].as_str(), Some("not sent")); assert_eq!(planned["repo_did"].as_str(), Some(REPO_DID)); assert!(world.issues(ALICE).is_empty(), "a dry run wrote a record");}
/// **An issue needs a body, whatever the lexicon says.**////// `sh.tangled.repo.issue` marks `body` optional; tangled.org's appview does/// not honour that. `Issue.Validate` refuses `issue body is empty` and the/// ingester logs `failed to ingest record, dropping it without retry`, so a/// title-only issue is accepted by the PDS, federates, and never appears —/// the same silent shape as a comment in the deprecated collection, reached/// from the other side. It has to be refused before the write, because after/// the write there is nothing to observe and nothing to undo.////// Exit `2`: the command line does not make sense, and no retry or re-login/// changes that.#[test]fn an_issue_with_no_body_is_refused_before_anything_leaves_the_machine() { let world = Scenario::new("issue-create-bodyless");
for args in [ vec!["issue", "create", "--title", "title only"], vec!["issue", "create", "--title", "title only", "--body", ""], vec![ "issue", "create", "--title", "title only", "--body", " \n ", ], // A dry run is refused too: it is meant to answer "would this work", // and this would not. vec!["issue", "create", "--title", "title only", "--dry-run"], ] { world.run(&args).refused_with(2, "issue body is empty"); } assert!( world.issues(ALICE).is_empty(), "a bodyless issue reached the PDS" );}
// ---------------------------------------------------------------------------// close and reopen// ---------------------------------------------------------------------------
/// State is a log. Closing appends a record and reopening appends another —/// neither deletes nor overwrites — and the newest one is what a read/// reports. This is the single most load-bearing assumption in `issue close`:/// a version that put over one key would look identical here for the first/// write and lose the history on the second.#[test]fn closing_appends_a_state_record_and_reopening_appends_another() { let world = Scenario::new("issue-state-log"); let rkey = open_issue(&world, "a bug"); let uri = format!("at://{ALICE}/{ISSUE_NSID}/{rkey}");
let closed = world .run(&["issue", "close", &rkey, "--json"]) .success() .json(); assert_eq!(closed["changed"], true); assert_eq!(closed["state_before"].as_str(), Some("open")); assert_eq!(closed["state_after"].as_str(), Some("closed"));
let states = world.records(ALICE, ISSUE_STATE_NSID); assert_eq!(states.len(), 1, "{states:#?}"); assert_eq!(states[0].1["issue"].as_str(), Some(uri.as_str())); assert_eq!( states[0].1["state"].as_str(), Some("sh.tangled.repo.issue.state.closed") );
// The read agrees with the write, which is what says the two halves read // the same log by the same rule. let viewed = world .run(&["issue", "view", &rkey, "--json"]) .success() .json(); assert_eq!(viewed["state"].as_str(), Some("closed"));
let reopened = world .run(&["issue", "reopen", &rkey, "--json"]) .success() .json(); assert_eq!(reopened["state_before"].as_str(), Some("closed")); assert_eq!(reopened["state_after"].as_str(), Some("open")); assert_eq!( world.records(ALICE, ISSUE_STATE_NSID).len(), 2, "reopening replaced a record instead of appending one" ); let viewed = world .run(&["issue", "view", &rkey, "--json"]) .success() .json(); assert_eq!(viewed["state"].as_str(), Some("open"));
// And closing an already-closed issue writes nothing at all, while still // exiting 0 — `changed` is the field that tells the two apart. world.run(&["issue", "close", &rkey]).success(); let again = world .run(&["issue", "close", &rkey, "--json"]) .success() .json(); assert_eq!(again["changed"], false); assert!(again["uri"].is_null(), "a no-op wrote a record: {again:#}"); assert_eq!(world.records(ALICE, ISSUE_STATE_NSID).len(), 3);}
/// A repo owner closing somebody else's issue writes into their *own* PDS,/// naming the other account's record.////// This is the whole reason `issue close` works at all without write access/// to a stranger's repository, and a version that tried to write into the/// reporter's PDS would be refused by a real server while passing any test/// that only looked at what the command printed.#[test]fn a_repo_owner_closes_someone_elses_issue_from_their_own_pds() { let world = Scenario::new("issue-close-as-owner"); world .run_as( BOB, &[ "issue", "create", "--title", "bob reports a bug", "--body", "steps", ], ) .success(); let (rkey, _) = only_issue(&world, BOB); let uri = format!("at://{BOB}/{ISSUE_NSID}/{rkey}");
world.clear_journal(); let closed = world .run_as(ALICE, &["issue", "close", &uri, "--json"]) .success() .json(); assert_eq!(closed["changed"], true); assert_eq!(closed["author_did"].as_str(), Some(BOB)); // Nothing could settle the state before the write: Bob does not own the // repo, so his own PDS holding no state record proves nothing. `null` // rather than "open" is the difference between a fact and a guess. assert!(closed["state_before"].is_null(), "{closed:#}");
assert!( world.records(BOB, ISSUE_STATE_NSID).is_empty(), "the state record went into the reporter's PDS" ); let states = world.records(ALICE, ISSUE_STATE_NSID); assert_eq!(states.len(), 1, "{states:#?}"); assert_eq!(states[0].1["issue"].as_str(), Some(uri.as_str()));
// …under Alice's credentials, which is the half no record can show. let actors = world.with(|w| { w.journal .iter() .filter_map(|c| c.actor.clone()) .collect::<Vec<_>>() }); assert!(!actors.is_empty(), "nothing authenticated was sent"); for actor in &actors { assert_eq!(actor, ALICE, "a write went out as the wrong account"); }}
/// An account that neither filed the issue nor owns the repo is refused/// before anything is written, because Tangled would drop the record in/// silence — and the refusal has to name tangled.org, which is where a/// collaborator with push access really can do it.#[test]fn a_stranger_cannot_close_an_issue_and_is_told_where_to() { let world = Scenario::new("issue-close-standing"); let rkey = open_issue(&world, "alice's own issue"); let uri = format!("at://{ALICE}/{ISSUE_NSID}/{rkey}");
// Exit 4, not an unclassified 1: there is a session and it was refused // over somebody else's record, which docs/output.md spells as `Denied` — // the status that tells a script to try another account rather than to // retry this one. world .run_as(BOB, &["issue", "close", &uri]) .refused_with(4, "does not own the repo"); assert!( world.records(BOB, ISSUE_STATE_NSID).is_empty(), "a refused close still wrote a record" ); assert!(world.records(ALICE, ISSUE_STATE_NSID).is_empty());}
// ---------------------------------------------------------------------------// comment// ---------------------------------------------------------------------------
/// **The collection choice, pinned.** An issue comment is a/// `sh.tangled.feed.comment` whose `subject` is a strongRef at the issue, not/// a `sh.tangled.repo.issue.comment` — that collection is deprecated and the/// appview's ingester answers a create on it with/// `// no-op. sh.tangled.repo.issue.comment is deprecated`.////// `pullRoundIdx` must be absent. The field is documented as required when/// the subject is a pull, the appview enforces exactly that, and an issue has/// no rounds to index into — so setting it would be inventing a number.#[test]fn a_comment_is_a_feed_comment_pointing_at_the_issue_and_carries_no_round() { let world = Scenario::new("issue-comment"); let rkey = open_issue(&world, "a bug"); let uri = format!("at://{ALICE}/{ISSUE_NSID}/{rkey}"); let issue_cid = world.with(|w| { w.get(ALICE, ISSUE_NSID, &rkey) .expect("the issue was just written") .cid .clone() });
let written = world .run(&[ "issue", "comment", &rkey, "--body", "still on 0.15", "--json", ]) .success() .json(); assert_eq!(written["issue_uri"].as_str(), Some(uri.as_str()));
assert!( world .records(ALICE, "sh.tangled.repo.issue.comment") .is_empty(), "the comment went into the deprecated collection" ); let comments = world.records(ALICE, FEED_COMMENT_NSID); assert_eq!(comments.len(), 1, "{comments:#?}"); let comment = &comments[0].1; assert_eq!(comment["subject"]["uri"].as_str(), Some(uri.as_str())); // Not decoration: the appview refuses a comment whose subject CID does // not parse, so a record built without one federates and never renders. assert_eq!(comment["subject"]["cid"].as_str(), Some(issue_cid.as_str())); assert!( comment.get("pullRoundIdx").is_none(), "an issue has no rounds: {comment:#}" ); // The body is a ref-union member, so it has to carry its own `$type`; // `original` is the field the appview resolves @mentions out of. assert_eq!( comment["body"]["$type"].as_str(), Some("sh.tangled.markup.markdown") ); assert_eq!(comment["body"]["text"].as_str(), Some("still on 0.15")); assert_eq!(comment["body"]["original"].as_str(), Some("still on 0.15"));}
/// Anyone may comment, unlike closing: the appview's comment ingester/// performs no ACL lookup at all. A standing check here would refuse writes/// Tangled itself accepts.#[test]fn a_stranger_may_comment_on_an_issue() { let world = Scenario::new("issue-comment-stranger"); let rkey = open_issue(&world, "alice's issue"); let uri = format!("at://{ALICE}/{ISSUE_NSID}/{rkey}");
world .run_as(BOB, &["issue", "comment", &uri, "--body", "me too"]) .success(); assert_eq!(world.records(BOB, FEED_COMMENT_NSID).len(), 1); assert!( world.records(ALICE, FEED_COMMENT_NSID).is_empty(), "the comment landed in the wrong PDS" );}
// ---------------------------------------------------------------------------// edit// ---------------------------------------------------------------------------
/// `issue edit` replaces the fields it was given and leaves the rest of the/// record exactly as it found it. The read-modify-write is the whole risk:/// a version that rebuilt the record from its arguments would silently drop/// the body, the timestamp, or anything a future lexicon adds.#[test]fn editing_a_title_leaves_the_rest_of_the_record_alone() { let world = Scenario::new("issue-edit"); world .run(&[ "issue", "create", "--title", "typo in READEM", "--body", "second paragraph", ]) .success(); let (rkey, before) = only_issue(&world, ALICE);
let edited = world .run(&[ "issue", "edit", &rkey, "--title", "typo in README", "--json", ]) .success() .json(); assert_eq!(edited["title_changed"], true); assert_eq!(edited["body_changed"], false); assert_eq!(edited["changed"], true);
let (_, after) = only_issue(&world, ALICE); assert_eq!(after["title"].as_str(), Some("typo in README")); assert_eq!(after["body"], before["body"]); assert_eq!(after["createdAt"], before["createdAt"]); assert_eq!(after["repo"], before["repo"]);
// Re-running it changes nothing and still exits 0. `changed: false` is // the only thing that says so, which is why it is a field rather than an // exit code. let again = world .run(&[ "issue", "edit", &rkey, "--title", "typo in README", "--json", ]) .success() .json(); assert_eq!(again["changed"], false);
// And an edit of somebody else's issue is refused: the record lives in // its author's PDS and there is no way to write to it. `Denied`, like the // close, and for the same reason. let uri = format!("at://{ALICE}/{ISSUE_NSID}/{rkey}"); world .run_as(BOB, &["issue", "edit", &uri, "--title", "hijacked"]) .refused_with(4, "only an issue's author");}
/// **An edit may replace a body and may not empty one.**////// The other half of [`an_issue_with_no_body_is_refused_before_anything_leaves_the_machine`],/// and the worse half: an update tangled.org refuses is dropped, so clearing/// a body leaves the appview showing the paragraph the PDS no longer holds/// and nothing anywhere saying the two disagree. `--body ''` has to be told/// apart from no `--body` at all for this refusal to be possible, which is/// what the nested `Option` in `new_body` is for.#[test]fn an_edit_cannot_empty_a_body() { let world = Scenario::new("issue-edit-empty-body"); let rkey = open_issue(&world, "a bug"); let (_, before) = only_issue(&world, ALICE);
for empty in ["", " "] { world .run(&["issue", "edit", &rkey, "--body", empty]) .refused_with(2, "issue body is empty"); } let (_, after) = only_issue(&world, ALICE); assert_eq!(after["body"], before["body"], "the body was emptied anyway");
// Replacing it is still ordinary. world .run(&["issue", "edit", &rkey, "--body", "now with steps"]) .success(); let (_, edited) = only_issue(&world, ALICE); assert_eq!(edited["body"].as_str(), Some("now with steps"));}
// ---------------------------------------------------------------------------// list// ---------------------------------------------------------------------------
/// An account's issue collection mixes every repo it has ever filed against,/// so a repo-scoped listing has to filter — and `--all` has to not.////// The trap is specific and has bitten the pull listing already: without the/// filter, `issue list` in one checkout answers with issues filed on/// completely different repos, which reads as a bug in the repo rather than/// in the listing.#[test]fn a_listing_is_scoped_to_this_repo_unless_it_is_asked_not_to_be() { let world = Scenario::new("issue-list-scope"); open_issue(&world, "here"); world.with(|w| { w.plant( ALICE, ISSUE_NSID, "3zzzelsewhere1", serde_json::json!({ "$type": ISSUE_NSID, "repo": OTHER_REPO, "title": "somewhere else", "createdAt": "2026-08-01T00:00:00Z", }), ); });
let listed = world .run(&["issue", "list", "--state", "all", "--json"]) .success() .json(); let rows = listed.as_array().expect("an array"); assert_eq!(rows.len(), 1, "{listed:#}"); assert_eq!(rows[0]["title"].as_str(), Some("here")); assert_eq!(rows[0]["repo_did"].as_str(), Some(REPO_DID));
let everywhere = world .run(&["issue", "list", "--all", "--state", "all", "--json"]) .success() .json(); assert_eq!(everywhere.as_array().expect("an array").len(), 2);
// An empty listing is `[]` on stdout, never prose — a script must not // have to tell "no issues" apart from "the process died". let none = world .run_as(BOB, &["issue", "list", "--state", "all", "--json"]) .success(); assert_eq!(none.stdout.trim(), "[]");}
/// One walk of the state collection for a whole page, not one per row.////// `settle_states` used to call `state_events` inside its loop, so thirty/// issues meant thirty `listRecords` walks of the same collection — and sixty/// when the acting account was not the author. The records that come back are/// the same records every time; only the round trips multiply.////// The count is the assertion, but the states are checked beside it: a batch/// that fetched once and then attributed the events to the wrong rows would/// satisfy the count on its own.#[test]fn a_listing_walks_the_state_collection_once_however_many_rows_it_has() { let world = Scenario::new("issue-list-one-state-walk"); let first = open_issue(&world, "one"); let second = open_issue(&world, "two"); open_issue(&world, "three");
// Close two of the three, so the walk has something to attribute and // getting the attribution wrong is visible. world.run(&["issue", "close", &first]).success(); world.run(&["issue", "close", &second]).success();
let before = state_walks(&world); let rows = world .run(&["issue", "list", "--state", "all", "--json"]) .success() .json(); let walks = state_walks(&world) - before;
let rows = rows.as_array().expect("an array"); assert_eq!(rows.len(), 3, "{rows:#?}"); assert_eq!( walks, 1, "three rows took {walks} walks of the state collection" );
let state_of = |title: &str| { rows.iter() .find(|r| r["title"].as_str() == Some(title)) .and_then(|r| r["state"].as_str()) .map(str::to_string) }; assert_eq!(state_of("one").as_deref(), Some("closed")); assert_eq!(state_of("two").as_deref(), Some("closed")); assert_eq!(state_of("three").as_deref(), Some("open"));}
/// On a repo the reporter owns, an issue with no state record anywhere is/// open, and a `--state` filter can rely on it. Off such a repo it reads `?`/// and is filtered out of both states, with a note saying how many that hid.#[test]fn a_state_is_known_on_your_own_repo_and_unsettled_off_it() { let world = Scenario::new("issue-list-state"); open_issue(&world, "mine, on my repo");
let open = world.run(&["issue", "list", "--json"]).success().json(); assert_eq!(open.as_array().expect("an array").len(), 1); assert_eq!(open[0]["state"].as_str(), Some("open"));
// Bob's issue on Alice's repo: Bob owns no repo record for it, so his own // PDS holding no state record settles nothing. world .run_as( BOB, &[ "issue", "create", "--title", "bob's, on alice's repo", "--body", "steps", ], ) .success(); let listed = world .run_as(BOB, &["issue", "list", "--state", "all", "--json"]) .success() .json(); assert_eq!(listed.as_array().expect("an array").len(), 1); assert!(listed[0]["state"].is_null(), "{listed:#}");
// …and it is therefore in neither state, with the count said out loud // rather than the row vanishing. The footnote has to say the row is // *missing*: a `--state` filter matches no unsettled row, so it was // removed, and a note claiming it was "listed whatever --state said" // describes the opposite of what happened. let filtered = world.run_as(BOB, &["issue", "list", "--json"]).success(); assert_eq!(filtered.stdout.trim(), "[]"); assert!( filtered .stderr .contains("1 issue(s) are missing from this listing"), "a hidden row was not accounted for: {}", filtered.stderr );
// Under `--state all` nothing is hidden and the same row prints `?`, // which wants the opposite sentence and used to get no sentence at all: // the count was only taken when a filter was set, so the one listing // that actually shows a `?` was the one that never explained it. let everything = world .run_as(BOB, &["issue", "list", "--state", "all", "--json"]) .success(); assert!( everything.stderr.contains("1 issue(s) above show state ?"), "a visible ? went unexplained: {}", everything.stderr );
// `issue view` has no `--state` at all, so it gets a sentence of its own // rather than borrowing one that answers a question nobody asked. let (rkey, _) = only_issue(&world, BOB); let viewed = world.run_as(BOB, &["issue", "view", &rkey]).success(); assert!( viewed.stderr.contains("This issue's state reads ?"), "{}", viewed.stderr ); assert!( !viewed.stderr.contains("--state"), "issue view explained itself in terms of a flag it does not have: {}", viewed.stderr );}
/// A `--state` word this build has never heard of is a typo, and the listing/// used to answer one with "no issues for <did> on <did>" — which does not so/// much as echo the word, so nothing on screen said the flag had been/// misread. An empty listing given confidently in answer to a question nobody/// asked is exactly what this tree refuses to do about a stale index; a flag/// does not get to do it either.////// Refused before anything leaves the machine, which is the second half: the/// empty journal is what says the typo cost no round trip.#[test]fn a_state_this_build_does_not_know_is_refused_rather_than_matching_nothing() { let world = Scenario::new("issue-list-state-typo"); let rkey = open_issue(&world, "here"); world.clear_journal();
let refused = world .run(&["issue", "list", "--state", "opne"]) .refused_with(2, "invalid value 'opne'"); assert!( refused.stderr.contains("open, closed, all"), "the refusal did not name what --state takes: {}", refused.stderr ); world.with(|w| { assert!( w.journal.is_empty(), "a typo was worth a round trip: {:?}", w.labels() ); });
// And every word it does take still filters exactly as it did. let listed = |state: &str| -> usize { world .run(&["issue", "list", "--state", state, "--json"]) .success() .json() .as_array() .expect("issue list --json prints an array") .len() }; assert_eq!(listed("open"), 1); assert_eq!(listed("closed"), 0);
// Closing it moves the row between them, which is the half a filter that // quietly matched nothing would also have passed. world.run(&["issue", "close", &rkey]).success(); assert_eq!(listed("open"), 0); assert_eq!(listed("closed"), 1); assert_eq!(listed("all"), 1);}
/// A Tangled issue *number* is the appview's own id, is in no record, and is/// the first thing anybody types. The refusal has to say so — read as "that/// is not a record key" it sends somebody looking for a typo they did not/// make.#[test]fn an_issue_number_is_refused_with_the_reason() { let world = Scenario::new("issue-number"); world .run(&["issue", "view", "23"]) .refused_with(2, "looks like a Tangled issue number"); world .run(&["issue", "close", "23"]) .refused_with(2, "looks like a Tangled issue number");}
/// `--state` is applied before `--limit`, not after.////// Cutting to a screenful first meant the filter ran over rows that had/// already been dropped by count: thirty closed issues at the top of the/// collection left `issue list` printing nothing while the open ones sat/// just below the cut. Same shape as the walk's early stop, one step further/// down the pipeline, and it fired even when the walk had read everything.#[test]fn the_state_filter_runs_before_the_limit() { let world = Scenario::new("issue-list-filter-first"); world.with(|w| { for i in 0..35 { let rkey = format!("3iii{i:04}"); // The oldest five, so a listing that cuts to its default thirty // before filtering loses exactly them. let open = i < 5; w.plant( ALICE, ISSUE_NSID, &rkey, serde_json::json!({ "$type": ISSUE_NSID, "repo": REPO_DID, "title": format!("issue {i}"), "createdAt": format!("2026-01-01T{:02}:{:02}:00Z", i / 60, i % 60), }), ); w.plant( ALICE, ISSUE_STATE_NSID, &format!("3jjj{i:04}"), serde_json::json!({ "$type": ISSUE_STATE_NSID, "issue": format!("at://{ALICE}/{ISSUE_NSID}/{rkey}"), "state": match open { true => "sh.tangled.repo.issue.state.open", false => "sh.tangled.repo.issue.state.closed", }, "createdAt": "2026-01-02T00:00:00Z", }), ); } });
let listed = world.run(&["issue", "list", "--json"]).success(); let rows = listed.json(); let rows = rows.as_array().expect("an array"); assert_eq!( rows.len(), 5, "the open issues below the --limit cut went unlisted\n--- stderr ---\n{}", listed.stderr ); assert!( rows.iter().all(|r| r["state"] == "open"), "a closed issue survived --state open: {rows:#?}" );}
/// `--source web` is a refusal on both issue readers, not a word that parses/// and then does nothing.////// It used to be accepted everywhere `--source` is, and the flag's own help/// admitted it did nothing here: the page scrape behind `web` only ever read/// pull request pages, so an issue listing asked for it read one source fewer/// than the caller named and said nothing about that. Accepting a value with/// no effect is a promise to keep accepting it, so it becomes a `Usage`/// refusal naming the values that work before 1.0 rather than after.////// `issue view` refuses before it resolves anything, which is why this can/// name an issue that does not exist: a `--source` this command cannot/// honour is a fact about the command line and needs no network to settle.#[test]fn source_web_is_refused_on_both_issue_readers() { let world = Scenario::new("issue-source-web"); for args in [ vec!["issue", "list", "--source", "web"], vec!["issue", "list", "--source", "pds,web"], vec!["issue", "view", "3msg7w7l6hs2x", "--source", "web"], ] { world .run(&args) .refused_with(2, "not a source for issues") .refused("pds, bobbin"); }}
/// **An author is named, not identified.** `issue close` printed/// `author: did:plc:…` — the raw DID — where every listing beside it prints/// `@handle`. A DID cannot be told from another at a glance, cannot be typed/// back into any command, and introduces the concept to a reader who never/// asked about it. The sweep that fixed the listings missed the three write/// verbs that print this line, because they format the DID directly rather/// than through a resolved label.#[test]fn closing_an_issue_names_its_author_by_handle() { let world = Scenario::new("issue-close-names-author"); let rkey = open_issue(&world, "a bug");
let run = world.run(&["issue", "close", &rkey]).success();
assert!( run.stdout.contains("author: @alice.test"), "the author was not named by handle\n--- stdout ---\n{}", run.stdout ); assert!( !run.stdout.contains("author: did:plc:"), "the raw DID is still printed\n--- stdout ---\n{}", run.stdout );}