Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658//! Listing pull requests: `atgc pr list`, `pr list --all` and `pr view`.//!//! The read half of [`crate::cmd::pr`]. Nothing in here authenticates: a//! `sh.tangled.repo.pull` record is public, so is every//! `com.atproto.repo.listRecords` call that fetches one, and so is Bobbin. A//! session buys this half exactly one thing — a DID whose PDS is worth//! reading — and its absence costs that and no more.//!//! What makes the half awkward is that a pull request has two homes and//! neither is complete. The record lives in its **author's** PDS, so your own//! pulls are one public request away and are visible the instant the write//! returns; everyone else's are scattered across the PDSes of people nothing//! enumerates, which is why answering "what pulls exist on this repo" needs//! an index and why [`Source`] exists to name which sources a listing used.//! So `pr list --all`, scoped to one account, can be answered off a PDS alone and//! never be stale, while `pr list`, scoped to a repo, cannot — and on//! 2026-08-06 Bobbin returned nothing at all for a repo whose author's PDS//! held thirty pull records aimed at it.//!//! Which is why this is a directory rather than a file. It had reached 4,287//! lines, and the seam is not the verbs: `pr list`, `pr list --all` and//! `pr view` all print different shapes of the same answer, and all three//! spend most of their length working out what that answer is.//!//! - [`mod@sources`] decides what the set of pull requests *is*: which//! sources were asked, how their answers are merged, what a disagreement//! between them is evidence of, and which states are known rather than//! guessed. It is the paragraph above, in code.//! - [`mod@labels`] turns the identifiers in those records into the things a//! row is called: an author's handle, a repo's `owner/name` and appview//! root, the appview's `#<n>`. Neither of the other two owns it — the//! listing needs it for its columns, and the backfill needs a repo's web//! root to scrape — so it is its own module rather than a helper inside//! whichever one reached for it first.//! - This file is what is left: the clap arguments, the two listing verbs,//! `pr view`, and the `--json` shapes all three emit.//!//! The split is a move and nothing else. No function was renamed, no//! signature changed, no printed string altered; the edits that are not a cut//! and a paste are this paragraph, the three submodule headers, a handful of//! `pub(super)`s on items that had been private to one file, five intra-doc//! links repointed at paths that still exist, and the re-exports below, which//! exist so that every `crate::cmd::pr::read::…` path other modules already//! use still resolves.
mod labels;mod sources;
#[cfg(test)]mod fixtures;
// The read half's public face, unchanged by the split: every one of these// was `read::<name>` before the file became a directory and still is.pub(in crate::cmd) use crate::model::pull::StackRow;pub(super) use labels::target_repo_did;pub(crate) use sources::{Source, pds_pulls};pub(in crate::cmd) use sources::{branch_pull_record, for_branch, pull_page, repo_rows};
use crate::clients::atproto::pds::Reach;use crate::clients::git::run as git;use crate::clients::tangled::resolve;use crate::cmd::listing::cuts;use crate::lexicon::tangled::PullState;use crate::term::column::{cell, day, pad_to};use crate::term::text::one_line;use anyhow::Result;use labels::{RepoName, author_did, author_labels, numbers_across_repos, repo_names};use sources::{ Ask, Empty, Scope, State, Whose, apply_state_filter, classify_empty, gather, gather_branch_pulls, note_author_scoped, note_unknown_states, own_did, require_branch_pull, unknown_states, warn_stale,};
/// The words `--state` accepts, on either pull listing.////// A `ValueEnum` rather than a `String` because the string took anything:/// `--state opne` matched no row, printed "no opne pull requests", and read/// as *there are none*. That is the failure this tree spent a release/// arguing about — an answer given in the shape and with the confidence of a/// real one — reproduced locally by a flag, and it costs one enum to make it/// a refusal that names the four words instead. clap prints them in `--help`/// as a side effect.////// The *input* side alone. What a record says a pull's state is stays/// [`sources::State::Known`]'s unconstrained `String`, holding whatever word the PDS/// or the index returned, for the reason spelled out there and in/// [`PullState::from_token`]: a state Tangled adds after this build has to/// list rather than crash. A filter is typed by a person and can be a typo; a/// status record is somebody else's fact and cannot be.////// `All` filters nothing, which also makes it the only way to see a pull/// whose state nothing available could settle: an unknown state matches no/// filter, so every other value hides those rows (and says how many).#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum StateFilter { Open, Closed, Merged, /// Every state, including a pull whose state is unknown All,}
impl StateFilter { /// The label a row's state is compared against, or `None` for `all`: /// exactly the shape [`apply_state_filter`] and [`classify_empty`] want, /// where `None` means "keep everything". fn label(self) -> Option<&'static str> { match self { StateFilter::Open => Some(PullState::Open.label()), StateFilter::Closed => Some(PullState::Closed.label()), StateFilter::Merged => Some(PullState::Merged.label()), StateFilter::All => None, } }}
impl std::fmt::Display for StateFilter { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.label().unwrap_or("all")) }}
#[derive(clap::Args, Debug)]pub(crate) struct ListArgs { /// Every repo instead of this one: your pulls wherever you filed them /// /// Widening is per author and not per repo, because the records are read /// from a PDS: a pull lives in the PDS of whoever wrote it, so "every /// repo" is answerable for one account and there is no listing of /// everyone's pulls everywhere to ask for. It is you unless `--author` /// names somebody else, and it needs no checkout. #[arg(long)] pub all: bool, /// Show another account's pull requests (handle or DID) /// /// Bare, this narrows *this repo* to that account. With `--all` it is /// their pulls wherever they filed them. Either way it reads their PDS /// rather than yours, which needs no token and no permission — only the /// DID a handle resolves to. #[arg(long, value_name = "HANDLE|DID")] pub author: Option<String>, /// Filter by state #[arg(long, default_value = "open")] pub state: StateFilter, /// Maximum number of PRs to show /// A floor of 1, the same `--limit` `search` has carried since it /// shipped. `--limit 0` used to parse and then print "no open pull /// requests" — an empty listing that reads as *there are none*, which is /// the stale-index failure this tree opted out of arriving from the /// command line instead. It is now clap's own refusal, naming the range, /// exit 2: the same treatment `--state opne` got and for the same reason. #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u32).range(1..))] pub limit: u32, /// Git remote pointing at the repo. Ignored with --all, which is not /// scoped to a checkout at all #[arg(long, default_value = "origin")] pub remote: String, /// Where to read from: pds (default, your own pulls and never stale), /// bobbin, web, auto for all three, or a comma-separated combination. /// Both indexes are opt-in; ATGC_USE_BOBBIN=1 and ATGC_USE_WEB=1 add /// one to the default, and ATGC_BOBBIN points at another instance #[arg(long)] pub source: Option<String>, /// Print a JSON array instead of a table, one object per pull; /// colour, hyperlinks and ellipsizing are all off (see --help for /// the field contract's stability note) #[arg(long)] pub json: bool,}// ---------------------------------------------------------------------------// `--json`// ---------------------------------------------------------------------------//// `pr list` and `pr view` are the two read commands an agent workflow leans// on hardest — `pr list` to see what is there, `pr view` to act on one — so// they are the pair that got `--json`, ahead of the wide listing, `stack view`,// `repo view` and `repo list`, which stay text-only for now (see TODO.md).//// Both emit the *derived* view: the same state, round count and resolved// author handle the human table or detail already compute, not the raw// `sh.tangled.repo.pull` record. The record is deliberately not the answer// here, for a reason specific to this lexicon rather than a general// preference: a pull's state and appview number are not fields on the// record at all (see docs/output.md, "What the record does and does// not say") — they are exactly the two things `pr list` exists to resolve,// so a `--json` that printed the record would omit the entire reason to run// the command and hand the caller the same two-source merge this file// exists to do, unauthenticated calls and all. A caller who wants the raw// bytes anyway can already fetch them, unauthenticated, with// `com.atproto.repo.getRecord` off the `uri` this prints.//// This diverges from `logs oauth --json` on purpose. That command re-emits the// exact bytes it read (`entry.raw`) because the log *is* the record — a// line of it already is the answer, and re-serializing would risk disagreeing// with what `jq` was already parsing. Here the record is raw input to a// computation the record itself cannot answer, so printing it back would not// be honest in the same way; it would just be incomplete.//// Both builders below are pure functions of data their caller has already// resolved (no I/O, no formatting decisions left to make), which is what// lets a test pin their shape against a fixture record with no network and// no mock server — see the tests at the end of this module.//// Stability: this project makes none of the guarantees a 1.0 CLI would (see// README.md, "no stability guarantees"). Within that, these two shapes// follow the same rule every other flag does — CONTRIBUTING.md's versioning// section — a field can be added in a `feat`, but removing or renaming one// is a breaking change and needs a `!`. Nothing here is guaranteed never to// change; it is guaranteed to say so with a version bump when it does.
/// `None` for the `"?"` [`sources::State::Unknown`] renders as, `Some` otherwise.////// Shared by every builder in the tool — [`crate::cmd::stack::read::view`]'s included —/// so "unknown" has exactly one spelling in the output. The alternative — printing the literal string `"?"` a script/// would have to special-case — is what the human column does and is a/// display convention, not a value; JSON already has a way to say "we do/// not know this", and using it is the whole point of typing the output.pub(in crate::cmd) fn known_state(label: &str) -> Option<String> { (label != "?").then(|| label.to_string())}
/// One row of `pr list --json`.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct PullRowJson { pub uri: String, /// The appview's `/pulls/<n>` number, when [`crate::clients::tangled::web::pulls::numbers_for_records`] /// could place this row. `null` rather than an empty column. pub number: Option<u32>, pub state: Option<String>, pub title: String, pub author_did: Option<String>, /// Without the leading `@` the terminal column prints — a caller wanting /// a mention back can add it; wanting the bare handle (to build a URL, to /// pass to `--author`) is at least as common and this way needs no /// stripping. pub author_handle: Option<String>, /// RFC 3339, straight from the record's `createdAt`. `null` for the rare /// pre-rounds record that carries `""` here — an empty string is not a /// timestamp, and a script parsing this field should not have to /// special-case one record era to find that out. pub created_at: Option<String>, pub rounds: usize, pub comments: u64,}
/// Build one [`PullRowJson`] from data `list` has already resolved.////// `envelope` is a merged item's `{"uri", "value"}` shape (a [`sources::Listed::value`]/// or the record wrapped the same way `pds_pulls` returns it) — the same/// value the text table reads every field of this from. `state` is/// [`sources::State::label`]'s output, `number` is what the appview-number sweep/// resolved for this row if anything, and `author_handle` is the same/// lookup the text column prints, without its `@`.pub(super) fn pull_row_json( envelope: &serde_json::Value, state: &str, number: Option<u32>, author_handle: Option<&str>, comments: u64,) -> PullRowJson { let v = &envelope["value"]; PullRowJson { uri: envelope["uri"].as_str().unwrap_or_default().to_string(), number, state: known_state(state), title: v["title"].as_str().unwrap_or("(untitled)").to_string(), author_did: author_did(envelope), author_handle: author_handle.map(str::to_string), created_at: v["createdAt"] .as_str() .filter(|s| !s.is_empty()) .map(str::to_string), rounds: crate::model::pull::round_count(v), comments, }}
/// One row of `status pr --json`: a `pr list` row plus the repo it targets.////// Flattened rather than nested so the two listings really do print the same/// fields for the same pull — a caller that reads `pr list --json` and then/// widens to every repo should not have to move `.state` to `.pull.state`/// on the way. What `status pr` adds is the column its table adds, and/// nothing else.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct StatusRowJson { #[serde(flatten)] pub pull: PullRowJson, /// The repo the pull targets. Present whenever the record names one at /// all, resolved or not — it is the identifier both lookups below start /// from, and the one a caller can act on. pub repo_did: Option<String>, /// `owner/name`, without the `@` the table's column prints, and `null` /// when neither Bobbin nor the appview could name the repo. The text /// column falls back to a truncated DID there; that string has an /// ellipsis in it and names nothing fetchable, so it is not a value. /// `repo_did` still carries the identity, exactly as `author_did` /// survives an unresolved `author_handle`. pub repo: Option<String>, pub repo_url: Option<String>,}
/// Build one [`StatusRowJson`] from data `status` has already resolved.////// `repo` is the same [`RepoName`] the text column reads, `None` for a/// record naming no target repo at all. Pure, like the two builders above.fn status_row_json( envelope: &serde_json::Value, state: &str, number: Option<u32>, author_handle: Option<&str>, comments: u64, repo_did: Option<String>, repo: Option<&RepoName>,) -> StatusRowJson { // `web_url` is `Some` exactly when the label came from a lookup rather // than from the truncated-DID fallback — the invariant `RepoName`'s own // doc states — so it is what decides whether there is a name here. let named = repo.filter(|name| name.web_url.is_some()); StatusRowJson { pull: pull_row_json(envelope, state, number, author_handle, comments), repo_did, repo: named.map(|name| name.label.trim_start_matches('@').to_string()), repo_url: named.and_then(|name| name.web_url.clone()), }}
/// One round of `pr view --json`'s `rounds` array.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct RoundJson { /// One-based, matching every `--round` flag `pr diff`, `pr checkout` and /// `pr comment` take — not Tangled's own zero-based `/round/<i>` URLs. /// See docs/output.md, "Comments attach to a round". pub index: usize, pub created_at: Option<String>, pub patch_bytes: Option<u64>,}
/// One member of `pr view --json`'s `stack.members`, when the pull is/// stacked.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct StackMemberJson { /// 1-based, counted from the bottom of the stack — the same numbering /// `atgc stack view` prints, even though both that command and this /// array list members top first. pub position: usize, /// Not printed by the human view, which shows a member's state and title /// only. Included here because an agent's next move on a stack member is /// almost always to act on it by URI (`pr checkout`, `pr view <uri>`), /// and the identifier is already in hand — leaving it out would just /// mean a second command to go and scrape it back out. pub uri: String, pub state: Option<String>, pub title: String,}
/// `pr view --json`'s `stack` field, present exactly when the pull belongs/// to one.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct StackJson { pub total: usize, /// Which member the surrounding object is describing, 1-based from the /// bottom — `total` when it is the top. Naming a member now details that /// member, so a consumer cannot assume the top the way it once could; /// `null` only if the chain walk and the detail disagree. pub position: Option<usize>, /// Top first, matching the order the human view prints them in — and /// including the pull this whole object is about, at its own position, /// the same way the text loop does. pub members: Vec<StackMemberJson>, /// A `dependentOn` link the listing used to build this view did not /// contain. See [`crate::cmd::stack::Chain::missing_below`]. pub missing_below: Option<String>,}
/// `pr view --json`'s whole object.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct PullDetailJson { pub uri: String, pub state: Option<String>, pub title: String, pub author_did: Option<String>, pub author_handle: Option<String>, pub created_at: Option<String>, pub comments: u64, pub rounds: Vec<RoundJson>, /// `null` for an empty or whitespace-only body, matching the text view's /// own "nothing worth printing" test. pub body: Option<String>, /// The confirmed `/pulls/<n>` page, and `null` when the appview has no /// number for this pull yet — [`crate::clients::tangled::web::pulls::PullLink::numbered_url`]. /// Never the repo's `/pulls` listing, which the text view prints with a /// note saying it is not the pull; there is nowhere to put that note /// here, so the field says nothing instead of saying it wrongly. pub url: Option<String>, pub stack: Option<StackJson>, /// The discussion, oldest first, or absent when it was not asked for. /// /// Distinct from `comments` above, which is the *count* the listing /// envelope carries and is there whether or not anybody asked for the /// bodies. `issue view --json` spells this field the same way. #[serde(skip_serializing_if = "Option::is_none")] pub thread: Option<Vec<crate::cmd::CommentJson>>,}
/// Build a [`PullDetailJson`] from data `view` has already resolved.////// `item` is the top pull's `{"uri", "value"}` shape, `state` is/// [`sources::State::label`]'s output for it, `url` is the already-resolved `view:`/// link when it names this pull and `None` when it does not, and `stack` is/// `None` for a pull that stands alone. A pure function of already-resolved/// inputs, same as [`pull_row_json`].pub(super) fn pull_detail_json( item: &serde_json::Value, state: &str, author_handle: Option<&str>, comments: u64, url: Option<&str>, stack: Option<StackJson>, thread: Option<Vec<crate::cmd::CommentJson>>,) -> PullDetailJson { let v = &item["value"]; let rounds = match v["rounds"].as_array() { Some(rounds) => rounds .iter() .enumerate() .map(|(i, round)| RoundJson { index: i + 1, created_at: round["createdAt"] .as_str() .filter(|s| !s.is_empty()) .map(str::to_string), patch_bytes: round["patchBlob"]["size"].as_u64(), }) .collect(), // Old records: a single inline patch, no rounds array — the text // view's own fallback, mirrored. None => vec![RoundJson { index: 1, created_at: v["createdAt"] .as_str() .filter(|s| !s.is_empty()) .map(str::to_string), patch_bytes: v["patch"].as_str().map(|s| s.len() as u64), }], }; PullDetailJson { uri: item["uri"].as_str().unwrap_or_default().to_string(), state: known_state(state), title: v["title"].as_str().unwrap_or("(untitled)").to_string(), author_did: author_did(item), author_handle: author_handle.map(str::to_string), created_at: v["createdAt"] .as_str() .filter(|s| !s.is_empty()) .map(str::to_string), comments, rounds, body: v["body"] .as_str() .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string), url: url.map(str::to_string), stack, thread, }}/// This repo's pull requests, or — with `--all` — one account's across every/// repo.////// One verb and a scope flag, rather than two verbs. The two listings print/// nearly the same columns and differ in exactly one thing, which is which/// pulls are in them; when that was carried by the choice between the words/// `list` and `status`, it was carried by nothing a reader could see. A flag/// named for the scope sits among the other filters, where the rest of the/// question is already being asked.////// The asymmetry underneath is real and is why the flag is not simply "more/// rows": this repo's pulls are gathered by repo and an account's are gathered/// by author, because a pull record lives in its author's PDS whatever it/// targets. See [`Scope`].pub(super) async fn list(args: ListArgs) -> Result<()> { crate::term::jsonout::init(args.json); match args.all { true => across_repos(args).await, false => in_this_repo(args).await, }}
/// The spelling `--author` was given, presentable: a handle wears its `@`/// and a DID is left exactly as typed.////// Deliberately the *input* and not the DID it resolved to. Somebody who/// asked about `@bob.example.com` is owed sentences about `@bob.example.com`;/// answering them about `did:plc:…` is correct and unreadable.fn author_label(input: &str) -> String { match crate::lexicon::identity::looks_like_a_did(input) { true => input.to_string(), false => format!("@{}", input.trim_start_matches('@')), }}
/// The author DID out of `at://<did>/sh.tangled.repo.pull/<rkey>`.////// A pull's authority *is* its author — the record lives in that account's/// PDS — so the at-uri carries the filter's answer without touching the/// record. `None` for anything that is not one, which then matches no/// filter.fn uri_author(uri: &str) -> Option<&str> { crate::model::record::authority_of(uri)}
async fn in_this_repo(args: ListArgs) -> Result<()> { let source = Source::parse_arg(args.source.as_deref())?; let remote_url = git::remote_url(&args.remote)?; let repo = resolve::repo_ref(&remote_url).await?; // `--author` narrows this repo's listing to one account, which is the // whole of what it takes: a repo listing's complete half is one PDS // already, so naming somebody else only changes *which* PDS. Reading it // needs no token, only a DID — the same reason `--all --author` works // with a session that has lapsed. let author = match &args.author { Some(who) => Some(crate::config::account::actor_did(who).await?), None => None, }; let me = match (source.uses_pds(), &author) { (false, _) => None, (true, Some(did)) => Some(did.clone()), (true, None) => own_did().await, }; // Named by the spelling that was typed rather than by the DID it // resolved to: somebody who asked for `@bob.example.com` should not be // answered about `did:plc:…`. Falls back to `Mine` when the account // named is the selected one, so `--author` on yourself reads like the // bare listing rather than like a stranger's. let whose = match (&author, &me) { (Some(_), Some(did)) if own_did().await.as_deref() == Some(did) => Whose::Mine, (Some(_), _) => Whose::Named(author_label(args.author.as_deref().unwrap_or_default())), (None, _) => Whose::Mine, };
// Read before the listing is fetched rather than after, because it is // what decides how deep to read: `--state` is applied here, over records // whose state the walk cannot see. See [`Reach::for_listing`]. let filter = args.state.label();
let mut gathered = gather(Ask { source, scope: Scope::Repo, did: me.as_deref(), endpoint: "listPulls", subject: &repo.did, repo_filter: Some(&repo.did), limit: args.limit, reach: Reach::for_listing(filter, args.limit), }) .await?;
// Before the listing rather than after it: the warning describes what // follows, and it is also the one part worth reading when the filter // empties the list and the early return below fires. warn_stale( &gathered.missing, gathered.pds_total, Scope::Repo, gathered.backfilled, &whose, );
// Copy, and read before the rows are moved on: it describes the walk, // not the listing, and every sentence below that says what is missing // says it from here. let evidence = gathered.evidence;
// The PDS half is already one account's; an index source is not, so // `--author` has to narrow what Bobbin or the web scrape brought back // too. Done here rather than in `gather` because it is this command's // filter and not a property of the sources — and after the evidence is // read, which is about the gather that really ran. if author.is_some() { let wanted = author.as_deref(); gathered.items.retain(|i| uri_author(&i.uri) == wanted); } let unfiltered = gathered.items.len(); let mut items = apply_state_filter(gathered.items, filter); // What `--limit` is about to cut down, which is one of the two things // `cuts` reports and the one no pull listing used to mention at all. let matched = items.len(); items.truncate(args.limit as usize); // After the cut, because the note speaks for the rows that print. let unknown = unknown_states(&items);
if items.is_empty() { // `[]` on stdout either way — a script should never have to tell // "no pulls" apart from "the process died before printing anything". // The reason still goes out, on stderr rather than the stdout the // prose version uses, so it is not lost, only moved off the stream // something downstream is about to parse. if args.json { crate::term::say::note!( Index, "no pull requests to list ({})", match classify_empty(filter, unfiltered) { Empty::NotIndexed => "none found; run without --json for why that might be index lag", Empty::NoneInState => "none in the requested state", } ); println!("[]"); return Ok(()); } match classify_empty(filter, unfiltered) { Empty::NotIndexed => { // Three different claims, and printing the strongest one // where only the weakest is earned is the whole bug this // change is about. Without an index in the source set // nothing here has looked at anybody else's pulls, so the // honest sentence is about *you*, not about the repo. match (me.is_some(), source.spans_authors()) { (true, false) => { println!("{} no pull requests on {}", whose.has(), repo.linked()); println!( "Read from {} PDS alone, so this says nothing about anyone \ else's. Add an index to look: --source bobbin (alpha, and \ its ingest stalls) or --source web (tangled.org's own, scraped).", whose.their(), ); } // Every source asked came back empty, and one of them // cannot be behind. As close to "this repo has no // pulls" as this tool gets. (true, true) => { println!("no pull requests found for {}", repo.linked()); println!( "{} PDS has none for it either, so this is probably right \ rather than index lag.", whose.possessive_leading(), ); } // The arm every new user meets, and until now the least // helpful of the three: it reported an absence in atgc's // own vocabulary and named nothing to type. A stranger // running `atgc pr list` in a repo with six open pulls // was told there were none, correctly and uselessly. (false, _) => { println!("no pull requests found for {}", repo.linked()); println!( "No account is selected, so your own PDS was not read, and an \ index can be behind — nothing here has ruled out there being \ some. Look with --source web (tangled.org's own, scraped) or \ --source bobbin (alpha, and its ingest stalls), or read your \ own with `atgc auth login <handle|did>`." ); } } } Empty::NoneInState => println!("no {} pull requests", args.state), } println!( "view: {}", crate::term::hyperlink::url(&format!("{}/pulls", repo.web_url)) ); return Ok(()); }
note_author_scoped( source, me.is_some() && author.is_none(), evidence.complete(), );
// One lookup per unique author DID, run together rather than in turn. let labels = author_labels(&items).await;
// Numbers are the appview's and are in no record, so they are a separate // errand — one that reads three listing pages once for the whole page of // rows rather than anything per row. A row it cannot place keeps the blank // column it has always had. let wanted: Vec<(&str, &str)> = items .iter() .map(|item| { ( item.uri.as_str(), item.value["value"]["title"].as_str().unwrap_or_default(), ) }) .collect(); let numbers = crate::clients::tangled::web::pulls::numbers_for_records(&repo.web_url, &wanted).await;
// `--json` skips the whole table below: ellipsizing, column padding and // hyperlink-wrapping are display decisions for a terminal, and every one // of them would have to be undone by anything reading the output back — // constraint 4 in the brief this shipped against says so explicitly, // and this is that: no `ellipsize`, no `hyperlink::url`, no width math, // unconditionally, not merely because stdout is a pipe. // // `term::text::one_line` is on that list too, and for the same reason // plus one of its own: it exists because a terminal *interprets* a title, // and a JSON document is not a terminal. serde escapes control characters // on the way out, so the title arrives as the record holds it, which is // what a caller reading it back is owed. if args.json { let rows: Vec<PullRowJson> = items .iter() .map(|item| { // `labels` falls back to the bare DID, unprefixed, when // resolution failed — the text column is happy to print // that in the author slot, but a JSON `author_handle` that // silently held a DID would be indistinguishable from a // real handle, so only a label that actually starts with // `@` counts as one here. let author_handle = author_did(&item.value) .and_then(|d| labels.get(&d)) .and_then(|h| h.strip_prefix('@')) .map(str::to_string); pull_row_json( &item.value, item.state.label(), numbers.get(&item.uri).copied(), author_handle.as_deref(), item.comments, ) }) .collect(); crate::term::jsonout::emit(&rows)?; cuts(evidence, matched, items.len(), "your pull records"); note_unknown_states(unknown); return Ok(()); }
for item in &items { let v = &item.value["value"]; let state = item.state.label(); let number = match numbers.get(&item.uri) { Some(number) => format!("#{number}"), None => String::new(), }; let title = cell(v["title"].as_str().unwrap_or("(untitled)"), 54, 56); let author = author_did(&item.value) .and_then(|d| labels.get(&d).cloned()) .unwrap_or_else(|| "?".to_string()); let rounds = crate::model::pull::round_count(v); let comments = item.comments; let date = day(v["createdAt"].as_str().unwrap_or("")); println!( "{} {} {} {} {} rounds:{rounds} comments:{comments}", pad_to(&number, 5), pad_to(state, 7), title, pad_to(&crate::term::hyperlink::cut_account(&author), 24), pad_to(&date, 11) ); } println!( "\nview: {}", crate::term::hyperlink::url(&format!("{}/pulls", repo.web_url)) ); cuts(evidence, matched, items.len(), "your pull records"); note_unknown_states(unknown); Ok(())}
/// One account's pull requests across every repo.////// The listing where dropping Bobbin is not a compromise at all. Every record/// this half wants lives in one PDS — the subject's — so a single/// `listRecords` answers it completely, and no index can be behind on it./// Bobbin is still asked, because it is the only thing that knows about/// status records written by a maintainer who is not the author, and about/// comment counts. But it is now an enrichment: when it is stale or down,/// this still lists every pull, marking as `?` only the states it genuinely/// cannot see.////// Needs no checkout and no session: reading somebody's pulls wants a DID and/// nothing else, so this path stays available to an account whose session has/// expired, and from a directory that is not a git repo at all.async fn across_repos(args: ListArgs) -> Result<()> { let source = Source::parse_arg(args.source.as_deref())?; // Reading someone's pulls needs no token, only a DID — so this path // stays available for an account whose session has expired. let did = match &args.author { Some(author) => crate::config::account::actor_did(author).await?, None => crate::config::account::select().await?.did, };
let filter = args.state.label();
let gathered = gather(Ask { source, scope: Scope::Author, did: Some(&did), endpoint: "listPullsBy", subject: &did, repo_filter: None, limit: args.limit, reach: Reach::for_listing(filter, args.limit), }) .await?;
warn_stale( &gathered.missing, gathered.pds_total, Scope::Author, gathered.backfilled, &match &args.author { Some(who) => Whose::Named(author_label(who)), None => Whose::Mine, }, );
let evidence = gathered.evidence; let unfiltered = gathered.items.len(); let mut items = apply_state_filter(gathered.items, filter); let matched = items.len(); items.truncate(args.limit as usize); let unknown = unknown_states(&items);
if items.is_empty() { let why = match classify_empty(filter, unfiltered) { Empty::NoneInState => format!("no {} pull requests", args.state), Empty::NotIndexed => format!("no pull requests for {did}"), }; // `[]` on stdout and the reason on stderr, the same split `pr list` // makes: an empty listing is a result, not an absence of one. if args.json { crate::term::say::note!(Index, "{why}"); println!("[]"); return Ok(()); } println!("{why}"); return Ok(()); }
// One lookup per unique repo DID, run together rather than in turn. let repos = repo_names(&items).await;
let numbers = numbers_across_repos(&items, &repos).await;
if args.json { // The table has no author column — every row is the one account's — // so this handle is resolved for the JSON alone, once for the page. // It is what keeps the row shape genuinely the same as `pr list // --json`'s rather than the same minus a field. let subject_handle = crate::clients::atproto::handles::handle(&did).await; let rows: Vec<StatusRowJson> = items .iter() .map(|item| { let repo_did = target_repo_did(&item.value); // Only the subject's own handle is in hand. A row filed by // anyone else — which a Bobbin-sourced listing can carry — // reads `null` rather than borrowing this one. let author_handle = subject_handle .as_deref() .filter(|_| author_did(&item.value).as_deref() == Some(did.as_str())); status_row_json( &item.value, item.state.label(), numbers.get(&item.uri).copied(), author_handle, item.comments, repo_did.clone(), repo_did.as_deref().and_then(|d| repos.get(d)), ) }) .collect(); crate::term::jsonout::emit(&rows)?; cuts(evidence, matched, items.len(), "your pull records"); note_unknown_states(unknown); return Ok(()); }
for item in &items { let v = &item.value["value"]; let state = item.state.label(); let number = match numbers.get(&item.uri) { Some(number) => format!("#{number}"), None => String::new(), }; let title = cell(v["title"].as_str().unwrap_or("(untitled)"), 54, 56); // `label` is `@owner/name` off a handle an index resolved and a repo // name out of an at-uri — somebody else's string on both halves, and // the one column here that is a name rather than a record's own text. let repo = target_repo_did(&item.value) .and_then(|d| repos.get(&d).map(|name| one_line(&name.label))) .unwrap_or_else(|| "?".to_string()); let rounds = crate::model::pull::round_count(v); let comments = item.comments; let date = day(v["createdAt"].as_str().unwrap_or("")); println!( "{} {} {} {} {} rounds:{rounds} comments:{comments}", pad_to(&number, 5), pad_to(state, 7), title, pad_to(&repo, 28), pad_to(&date, 11) ); } cuts(evidence, matched, items.len(), "your pull records"); note_unknown_states(unknown); Ok(())}#[derive(clap::Args, Debug)]pub(crate) struct ViewArgs { /// The pull request: its number, record key, at:// URI, or Tangled URL /// /// Defaults to the one this branch is about. #[arg(value_name = "PULL")] pub pull: Option<String>, /// The same, as a flag, for symmetry with the other verbs #[arg(long, value_name = "PULL", conflicts_with = "pull")] pub pr: Option<String>, /// Whose pull request it is, when a bare record key is ambiguous #[arg(long, value_name = "HANDLE|DID")] pub author: Option<String>, /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// Also open the repo's pulls page in the browser #[arg(long)] pub web: bool, /// Also show the discussion (needs the index: --source bobbin) /// /// A comment is a record in its commenter's PDS and nothing enumerates /// the people who have commented, so a thread can only come from the /// appview's index, whose ingest stalls. #[arg(long)] pub comments: bool, /// Where to read from: pds (default), bobbin, web, auto for all three, /// or a comma-separated combination. Both indexes are opt-in; /// ATGC_USE_BOBBIN=1 and ATGC_USE_WEB=1 add one to the default #[arg(long)] pub source: Option<String>, /// Print a JSON object instead of the human view; colour, hyperlinks /// and day-truncated dates are all off (see --help for the field /// contract's stability note) #[arg(long)] pub json: bool,}
impl ViewArgs { /// The pull this names, whichever spelling was used. /// /// The positional and `--pr` are declared `conflicts_with` each other, so /// clap has already refused naming one pull twice; this only picks /// whichever of the two carries it. pub(crate) fn pull(&self) -> Option<&str> { self.pull.as_deref().or(self.pr.as_deref()) }}
/// A pull request's detail view: a named pull from anywhere, or the current/// branch's — and only that one.////// The command most damaged by index lag, because the pull it wants is/// almost always the one just opened — the least likely to be indexed and/// the one whose author's PDS is certainly yours. Reading the PDS first/// makes `pr view` work immediately after `pr create` instead of hours later.////// The match is `source.branch` and nothing else. It used to fall back to/// your newest pull on the repo, which sounds like a courtesy and is not/// one: run from a second worktree it answers with whatever you opened most/// recently, on any branch, and says nothing about having guessed. A wrong/// answer that looks right is worse than no answer, the more so because the/// `uri:` line it prints is what a person then feeds to `pr close`.pub(crate) async fn view(args: ViewArgs) -> Result<()> { crate::term::jsonout::init(args.json); let source = Source::parse_arg(args.source.as_deref())?; // Resolved up front so that a bad reference fails before any listing is // fetched. `pr view 23` is the spelling people try first, because every // sibling command (`diff`, `checkout`, `merge`, `edit`, `comment`) // accepts it; being the one refusal in the family taught nobody anything. let named = match args.pull() { Some(reference) => Some( crate::cmd::pr::review::resolve_pull( Some(reference), args.author.as_deref(), &args.remote, ) .await?, ), None => None, }; let remote_url = git::remote_url(&args.remote)?; let repo = resolve::repo_ref(&remote_url).await?; let gathered = gather_branch_pulls(source, &repo).await?; let items: Vec<&serde_json::Value> = gathered.items.iter().map(|i| &i.value).collect();
// A named pull is looked up in the listing so its state and chain can // be shown; one the index has not caught up with is shown from the // record alone, because the record in hand outranks the index that // lacks it. Only the no-argument form reads the checked-out branch — // naming a pull works from a detached HEAD. let unlisted: serde_json::Value; let item: &serde_json::Value = match &named { Some(pull) => match items .iter() .copied() .find(|i| i["uri"].as_str() == Some(pull.uri.as_str())) { Some(item) => item, None => { crate::term::say::note!( Index, "this pull is not in the repo listing yet (index lag, or another \ repo's pull): showing the record by itself" ); unlisted = serde_json::json!({ "uri": pull.uri, "value": pull.value }); &unlisted } }, None => { let branch = git::current_branch()?; require_branch_pull(&gathered.items, &branch)? } }; // A stacked branch matches every member of its stack — they all share // `source.branch` — so the newest match is only a way into the chain. // Before this, `pr view` on a stacked branch showed whichever member was // created last, with nothing to say others existed: a wrong answer in // the shape of a right one. With a chain, the top pull is the detail and // the chain is printed below it. // A chain that cannot be ordered — a fork or a loop in somebody's // records, which the appview refuses to ingest but a PDS happily holds — // must degrade this view, not destroy it. `stack view` is the command // that refuses loudly; `pr view` says what it saw and shows the newest // match flat, because "one damaged record on the branch" and "no answer // about your pull at all" are very different sizes of problem. // The closed pulls this listing knows about, so a stack somebody has // taken a member out of still orders. `gather_branch_pulls` carries each // one's state beside its record, which is the whole of what is needed. let closed: std::collections::HashSet<&str> = gathered .items .iter() .filter(|i| matches!(&i.state, State::Known(s) if s == "closed")) .map(|i| i.uri.as_str()) .collect(); let chain = match crate::cmd::stack::chain_containing( &items, item["uri"].as_str().unwrap_or_default(), &closed, ) { Ok(chain) => chain, Err(e) => { crate::term::say::warning!( Pds, "this branch's pulls do not form an orderable stack: {e}\n\ showing the newest match for the branch by itself" ); None } }; // Only the branch match is promoted to the top. A *named* pull is not a // way into the chain — it is the answer, and substituting the top for it // was this command's own "wrong answer in the shape of a right one": // every member of a stack reported the top's title, body, rounds and // state under the number, rkey, at:// URI or URL of the member asked // for, so two different members read back byte-identical. Nothing said // a swap had happened; in `--json` only `url` disagreed with the // question. The chain is still built either way — it is what the // `stack:` block below is drawn from. let item = detail_item(named.is_some(), chain.as_ref(), item); // Where the pull being detailed sits in its chain, 1-based from the // bottom, so both views can say which member this is rather than // asserting it is the top. let position = chain.as_ref().and_then(|chain| { chain .members .iter() .position(|m| m["uri"] == item["uri"]) .map(|i| i + 1) }); let found = gathered .items .iter() .find(|i| i.uri.as_str() == item["uri"].as_str().unwrap_or_default());
let v = &item["value"]; let title = v["title"].as_str().unwrap_or("(untitled)"); // One pull, one unresolved state: exactly the case the targeted web // confirm exists for. Auto only — `--source pds` asked for no third // parties and `--source bobbin` wants Bobbin's unpatched answer — and // only after every record-borne source has come up empty. A miss keeps // the `?`: the scrape is a witness, not a fourth source of truth. let mut state = found.map_or("?", |i| i.state.label()).to_string(); if state == "?" && source.uses_web() && let Some(confirmed) = crate::clients::tangled::web::backfill::state_of( &repo.web_url, item["uri"].as_str().unwrap_or_default(), title, ) .await { state = confirmed.to_string(); } // Resolved once and shared by both the human and `--json` paths below, // rather than the human column's `format!("@{h}")` baked in early: JSON // wants the DID and the bare handle as two separate, unambiguous fields // instead of one string that means different things depending on // whether the lookup succeeded. let author_handle = match author_did(item) { Some(did) => crate::clients::atproto::handles::handle(&did).await, None => None, }; let author = match (author_did(item), &author_handle) { (Some(_), Some(h)) => crate::term::hyperlink::handle(h), (Some(did), None) => did, (None, _) => "?".to_string(), }; let created = day(v["createdAt"].as_str().unwrap_or("")); let comments = item["commentCount"].as_u64().unwrap_or(0); let uri = item["uri"].as_str().unwrap_or("?"); let thread = crate::cmd::read_thread(args.comments, source, uri).await; // The number is the appview's and is in no record, so this is a lookup // rather than a format — and one that falls back to the repo's listing, // which is what the line said before it could ever be more specific. The // fallback says so now: a `view:` line that quietly widened to the whole // repo is the one link a reader cannot tell has widened. Resolved before // either rendering, since `--json` needs it too. let link = crate::clients::tangled::web::pulls::pull_url(&repo.web_url, uri, title).await;
if args.json { let stack = chain.as_ref().map(|chain| { let total = chain.members.len(); let members = chain .members .iter() .enumerate() .rev() .map(|(i, member)| { let member_uri = member["uri"].as_str().unwrap_or_default(); let member_state = gathered .items .iter() .find(|g| g.uri == member_uri) .map_or("?", |g| g.state.label()); StackMemberJson { position: i + 1, uri: member_uri.to_string(), state: known_state(member_state), title: member["value"]["title"] .as_str() .unwrap_or("(untitled)") .to_string(), } }) .collect(); StackJson { total, position, members, missing_below: chain.missing_below.clone(), } }); let detail = pull_detail_json( item, &state, author_handle.as_deref(), comments, link.numbered_url(), stack, crate::cmd::thread_json(&thread).await, ); crate::term::jsonout::emit(&detail)?; // On stderr in both modes, and for the same reason: it is the only // thing that says why `url` is null and why `--web` is about to open // a page the pull is merely somewhere on. if let Some(note) = link.note() { crate::term::say::note!(Index, "{note}"); } if args.web { crate::term::noinput::open_in_browser(link.url()); } return Ok(()); }
// `title` above is the raw record value on purpose: the two web lookups // it was handed to match it against the appview's own HTML, and a cleaned // string would not match a record whose title really does hold a control // character. Cleaning happens here, where it becomes a line. println!("{state} {}", one_line(title)); println!("author: {author}"); println!("created: {created}"); println!("comments: {comments}"); match v["rounds"].as_array() { Some(rounds) => { println!("rounds: {}", rounds.len()); for (i, round) in rounds.iter().enumerate() { let date = day(round["createdAt"].as_str().unwrap_or("?")); match round["patchBlob"]["size"].as_u64() { Some(size) => println!(" {}: {date}, patch {size} bytes", i + 1), None => println!(" {}: {date}", i + 1), } } } // Old records: a single inline patch, no rounds array. None => { println!("rounds: 1"); let size = v["patch"].as_str().map(str::len).unwrap_or(0); println!(" 1: {created}, patch {size} bytes"); } } if let Some(chain) = &chain { let total = chain.members.len(); // The top is the top whether or not the bottom could be read — a // dangling link is below, and nothing above it went missing. The // middle positions are the ones the floor applies to, so they say // what `Chain::size` says rather than an exact count. let which = match position { Some(p) if p == total => "this is the top".to_string(), Some(p) if chain.missing_below.is_some() => { format!("this is at least {p} of {}", chain.size()) } Some(p) => format!("this is {p} of {total}"), // The detail is in the chain by construction; a miss would mean // the walk and the item disagreed, and silence would be a lie. None => "this pull's place in it is unclear".to_string(), }; println!("stack: {total} pulls; {which}: `atgc stack view` for the chain"); for (i, member) in chain.members.iter().enumerate().rev() { let member_uri = member["uri"].as_str().unwrap_or("?"); let state = gathered .items .iter() .find(|g| g.uri == member_uri) .map_or("?", |g| g.state.label()); // Two spaces or an arrow, so the rows stay in one column and the // one being detailed is findable without counting. let mark = match position == Some(i + 1) { true => "> ", false => " ", }; println!( "{mark}{}/{total} {} {}", i + 1, pad_to(state, 7), one_line(member["value"]["title"].as_str().unwrap_or("(untitled)")), ); } if let Some(missing) = &chain.missing_below { println!(" ...continues below {missing}, which this listing does not contain"); } } if let Some(body) = v["body"].as_str().filter(|b| !b.trim().is_empty()) { // Somebody else's prose, printed as prose — `clean` rather than // `one_line`, since a body is allowed to be several lines and its // paragraphs are part of what it says. println!("\n{}", crate::term::text::clean(body).trim_end()); } println!(); println!("uri: {uri}"); println!("view: {}", crate::term::hyperlink::url(link.url())); crate::cmd::print_thread(&thread).await; if let Some(note) = link.note() { crate::term::say::note!(Index, "{note}"); } if args.web { crate::term::noinput::open_in_browser(link.url()); } Ok(())}/// The pull [`view`] details: the top of the chain when the branch was the/// question, and the pull itself when one was named.////// Kept apart from [`view`] for the same reason [`for_branch`] is — "naming/// a pull answers about that pull" is a rule a test should be able to hold/// still, and observing it through [`view`] would take a network. `named`/// is only whether the caller gave a reference; which record it resolved to/// is already `matched`.pub(super) fn detail_item<'a>( named: bool, chain: Option<&crate::cmd::stack::Chain<'a>>, matched: &'a serde_json::Value,) -> &'a serde_json::Value { match (chain, named) { (Some(chain), false) => chain.top(), _ => matched, }}#[cfg(test)]mod tests { use super::fixtures::{REPO_DID, bobbin_items, old_item}; use super::labels::RepoName; use super::{StackJson, StackMemberJson, pull_detail_json, pull_row_json}; use super::{detail_item, known_state, status_row_json}; use crate::model::pull::round_count; use crate::term::column::ellipsize; use serde_json::{Value, json};
/// Live records all carry `rounds`; pre-rounds ones carry one inline /// patch, which is one round however it is spelled. A zero here would /// print "rounds:0" against a PR that plainly has a patch in it. #[test] fn counts_rounds_across_both_record_shapes() { for item in &bobbin_items() { let expected = item["value"]["rounds"].as_array().unwrap().len(); assert_eq!(round_count(&item["value"]), expected); } let old = old_item(); assert!(old["value"].get("rounds").is_none()); assert_eq!(round_count(&old["value"]), 1); }
// ----------------------------------------------------------------------- // `--json`: pure serializers, pinned against real records // -----------------------------------------------------------------------
#[test] fn known_state_turns_the_question_mark_into_null_and_nothing_else() { assert_eq!(known_state("?"), None); assert_eq!(known_state("open"), Some("open".to_string())); assert_eq!(known_state("merged"), Some("merged".to_string())); }
/// The happy path: a live record with one round, a resolved number and a /// resolved handle, pinned field by field against what /// `bobbin_list_pulls_by.json`'s first item actually holds. #[test] fn pull_row_json_pulls_the_fields_the_table_already_computes() { let item = &bobbin_items()[0]; let row = pull_row_json(item, "merged", Some(42), Some("permadeath.com"), 3); assert_eq!(row.uri, item["uri"].as_str().unwrap()); assert_eq!(row.number, Some(42)); assert_eq!(row.state.as_deref(), Some("merged")); assert_eq!(row.title, "Include branch in image tag"); assert_eq!( row.author_did.as_deref(), Some("did:plc:nlzmjyfv6loqtxyzvdcznwgf") ); assert_eq!(row.author_handle.as_deref(), Some("permadeath.com")); assert_eq!(row.created_at.as_deref(), Some("2026-08-05T04:31:07+03:00")); assert_eq!(row.rounds, 1); assert_eq!(row.comments, 3); }
/// Everything unresolved comes back `null`, not the terminal column's /// `"?"` or an empty string — the whole reason this type exists rather /// than reusing the display strings. #[test] fn pull_row_json_unresolved_fields_are_null_not_placeholder_strings() { let item = &bobbin_items()[0]; let row = pull_row_json(item, "?", None, None, 0); assert_eq!(row.state, None); assert_eq!(row.number, None); assert_eq!(row.author_handle, None); // The DID is still there — only the *handle* was unresolved. assert!(row.author_did.is_some()); }
/// A pre-rounds record: no `rounds` array and an empty `createdAt`, /// which is real bytes off the network rather than an invented edge /// case — see `pull_old_record.json`'s own doc comment. #[test] fn pull_row_json_old_record_has_no_created_at_and_counts_one_round() { let item = old_item(); let row = pull_row_json(&item, "closed", None, None, 0); assert_eq!(row.created_at, None); assert_eq!(row.rounds, 1); }
/// /// The exact key set a `jq` pipeline would see, pinned as a shape rather /// than as the values inside it: a renamed field breaks a caller silently. #[test] fn pull_row_json_serializes_with_the_documented_field_names() { let item = &bobbin_items()[0]; let row = pull_row_json(item, "merged", Some(7), Some("permadeath.com"), 2); let value = serde_json::to_value(&row).unwrap(); let mut keys: Vec<&str> = value .as_object() .unwrap() .keys() .map(String::as_str) .collect(); keys.sort_unstable(); assert_eq!( keys, vec![ "author_did", "author_handle", "comments", "created_at", "number", "rounds", "state", "title", "uri", ] ); }
/// `status pr --json` is a `pr list --json` row with the repo columns /// flattened in beside it, not nested under one — a caller moving from /// one listing to the other reads `.state`, not `.pull.state`. #[test] fn status_row_json_flattens_the_pull_row_beside_the_repo() { let item = &bobbin_items()[0]; let repo = RepoName { label: "@permadeath.com/atgc".to_string(), web_url: Some("https://tangled.org/permadeath.com/atgc".to_string()), }; let row = status_row_json( item, "merged", Some(7), Some("permadeath.com"), 2, Some(REPO_DID.to_string()), Some(&repo), ); let value = serde_json::to_value(&row).unwrap(); let mut keys: Vec<&str> = value .as_object() .unwrap() .keys() .map(String::as_str) .collect(); keys.sort_unstable(); assert_eq!( keys, vec![ "author_did", "author_handle", "comments", "created_at", "number", "repo", "repo_did", "repo_url", "rounds", "state", "title", "uri", ] ); // No `@`, matching `author_handle` and every other JSON field that // holds a name a URL is built from. assert_eq!(value["repo"], "permadeath.com/atgc"); assert_eq!(value["state"], "merged"); }
/// A repo neither lookup could name keeps its DID and reads `null` for /// the name and the URL. The text column prints a truncated DID there — /// a display string with an ellipsis in it, which is not a repo anybody /// can fetch and so is not a value this may emit. #[test] fn status_row_json_unresolved_repo_is_null_not_a_truncated_did() { let item = &bobbin_items()[0]; let unresolved = RepoName { label: ellipsize(REPO_DID, 21), web_url: None, }; let row = status_row_json( item, "open", None, None, 0, Some(REPO_DID.to_string()), Some(&unresolved), ); assert_eq!(row.repo, None); assert_eq!(row.repo_url, None); assert_eq!(row.repo_did.as_deref(), Some(REPO_DID)); }
/// The old-record fallback `pr view` prints as a single inline round: /// one entry, its bytes taken from the inline `patch` string rather than /// a `patchBlob`. #[test] fn pull_detail_json_old_record_falls_back_to_one_inline_round() { let item = old_item(); let detail = pull_detail_json( &item, "closed", None, 0, Some("https://tangled.org/x/pulls/9"), None, None, ); assert_eq!(detail.rounds.len(), 1); assert_eq!(detail.rounds[0].index, 1); assert!(detail.rounds[0].patch_bytes.unwrap() > 0); assert_eq!(detail.created_at, None); assert_eq!(detail.stack, None); }
/// A live record's `rounds` array is walked one-for-one, one-based, with /// the round's own blob size rather than anything recomputed. #[test] fn pull_detail_json_lists_every_round_from_the_record() { let item = &bobbin_items()[0]; let detail = pull_detail_json( item, "merged", Some("permadeath.com"), 1, Some("https://tangled.org/x/pulls/1"), None, None, ); assert_eq!(detail.rounds.len(), 1); assert_eq!(detail.rounds[0].index, 1); assert_eq!(detail.rounds[0].patch_bytes, Some(5145)); assert_eq!(detail.state.as_deref(), Some("merged")); assert_eq!(detail.url.as_deref(), Some("https://tangled.org/x/pulls/1")); assert_eq!( detail.body.as_deref(), Some("Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>") ); }
/// A body that is present but only whitespace prints nothing in the /// human view, and reads `null` here for the same reason. #[test] fn pull_detail_json_blank_body_is_null() { let item = json!({ "uri": "at://did:plc:me/sh.tangled.repo.pull/3xyz", "value": { "title": "t", "body": " \n ", "createdAt": "2026-01-01T00:00:00Z" }, }); let detail = pull_detail_json( &item, "open", None, 0, Some("https://x/pulls/1"), None, None, ); assert_eq!(detail.body, None); }
/// The `stack` field is carried through exactly as built — this only /// pins that `pull_detail_json` does not touch it, since the assembly /// itself lives in `view` next to the chain it walks. #[test] fn pull_detail_json_carries_a_stack_through_untouched() { let item = &bobbin_items()[0]; let stack = StackJson { total: 2, position: Some(2), members: vec![StackMemberJson { position: 2, uri: "at://did:plc:me/sh.tangled.repo.pull/3top".to_string(), state: Some("open".to_string()), title: "top".to_string(), }], missing_below: Some("at://did:plc:me/sh.tangled.repo.pull/3below".to_string()), }; let detail = pull_detail_json( item, "open", None, 0, Some("https://x/pulls/1"), Some(stack), None, ); let stack = detail.stack.expect("stack should be Some"); assert_eq!(stack.total, 2); assert_eq!(stack.position, Some(2)); assert_eq!(stack.members.len(), 1); assert_eq!(stack.members[0].position, 2); assert_eq!( stack.missing_below.as_deref(), Some("at://did:plc:me/sh.tangled.repo.pull/3below") ); }
/// Naming a member of a stack details *that* member. This is the whole /// of the bug it replaces: the top was substituted for every named pull, /// so a stack of eight answered eight different numbers, rkeys, at:// /// URIs and URLs with one identical record, and nothing in the output /// said so. #[test] fn naming_a_stack_member_details_that_member_not_the_top() { let bottom = json!({ "uri": "at://did:plc:me/sh.tangled.repo.pull/3bot" }); let top = json!({ "uri": "at://did:plc:me/sh.tangled.repo.pull/3top" }); let chain = crate::cmd::stack::Chain { members: vec![&bottom, &top], missing_below: None, }; assert_eq!( detail_item(true, Some(&chain), &bottom)["uri"], bottom["uri"], "a named pull is the answer, not a way into the chain" ); }
/// The no-argument form keeps promoting to the top, which is what makes /// the chain readable from a stacked branch at all: every member shares /// `source.branch`, so the branch match alone is arbitrary. #[test] fn a_branch_match_is_still_promoted_to_the_top() { let bottom = json!({ "uri": "at://did:plc:me/sh.tangled.repo.pull/3bot" }); let top = json!({ "uri": "at://did:plc:me/sh.tangled.repo.pull/3top" }); let chain = crate::cmd::stack::Chain { members: vec![&bottom, &top], missing_below: None, }; assert_eq!(detail_item(false, Some(&chain), &bottom)["uri"], top["uri"]); }
/// A pull that stands alone is itself either way — no chain, nothing to /// promote to, and the named and branch paths must not diverge here. #[test] fn an_unstacked_pull_is_its_own_detail() { let only = json!({ "uri": "at://did:plc:me/sh.tangled.repo.pull/3one" }); assert_eq!(detail_item(true, None, &only)["uri"], only["uri"]); assert_eq!(detail_item(false, None, &only)["uri"], only["uri"]); }
/// The exact key set `pr view --json` prints, including that `stack` is /// present (as `null`) even for a pull that stands alone rather than /// being omitted — a script checking `.stack == null` should not have /// to also check `has("stack")`. #[test] fn pull_detail_json_serializes_with_the_documented_field_names() { let item = &bobbin_items()[0]; let detail = pull_detail_json( item, "merged", None, 0, Some("https://x/pulls/1"), None, None, ); let value = serde_json::to_value(&detail).unwrap(); let mut keys: Vec<&str> = value .as_object() .unwrap() .keys() .map(String::as_str) .collect(); keys.sort_unstable(); assert_eq!( keys, vec![ "author_did", "author_handle", "body", "comments", "created_at", "rounds", "stack", "state", "title", "uri", "url", ] ); assert_eq!(value["stack"], Value::Null); }}