//! `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![shot](shot.png)\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:///"); 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", "![shot](shot.png)", "--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", "![gone](nowhere.png)", ]) .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::>() }); 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::>() }); 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 on " — 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 ); }