//! What a pull request record means. use anyhow::Result; /// The branch a pull targets. /// /// Modern records carry `target.branch`; records written before that field /// existed carry a bare `targetBranch`, and reading the field that is /// actually there is not a guess. Defaulting to `main` when neither is /// present *is* one, and it is refused instead: this is the one place a /// wrong answer is unrecoverable, because it names the branch a merge /// writes to. /// /// This existed six times across the verb files. Five agreed. The sixth was /// on `pr merge`, spelled `.unwrap_or("main")`, and would have landed a /// patch on a branch nobody named. /// /// `named` is how the record is identified in the refusal — an at-uri where /// the caller has one, since "a pull here" is no help when several are being /// linked at once. pub(crate) fn target_branch(value: &serde_json::Value, named: &str) -> Result { target_branch_of(value).map(str::to_string).ok_or_else(|| { anyhow::anyhow!("{named} names no target branch; refusing to guess where this stack lands") }) } /// The two fields, without the refusal: for a reader that has something to /// say about "unknown" and does not have to pick a branch to write to. /// /// Three verb-local copies of this outlived the strict one, and they /// disagreed about the legacy field. The copy `pr create` uses to decide /// whether this branch already has an open pull aimed at the same target /// read only `target.branch`, so against a pre-`target` record it answered /// `None`, the comparison failed, and the warning about opening a second /// pull beside an existing one silently did not fire. pub(crate) fn target_branch_of(value: &serde_json::Value) -> Option<&str> { value["target"]["branch"] .as_str() .or_else(|| value["targetBranch"].as_str()) .filter(|b| !b.is_empty()) } /// The same, for a listing row, which carries the record under `value`. pub(crate) fn target_branch_of_row(row: &serde_json::Value) -> Result { target_branch( &row["value"], row["uri"].as_str().unwrap_or("a stack member"), ) } /// One row of a repo's merged listing: the merged item — record under /// `"value"`, at-uri under `"uri"` — and the state label the listing settled /// for it. /// /// Two facts about one pull, and every chain rule needs both: the walk /// follows `dependentOn` out of the item, and whether a member is closed /// decides whether the walk may read past it. It lived in the listing reader /// that first produced it, which put the type a chain is made of one layer /// above the rules about chains. pub(crate) struct StackRow { pub item: serde_json::Value, pub state: String, } /// How many review rounds a pull has. /// /// A record predating the rounds array carries a single inline `patch` and no /// array at all; it is one round by definition, and `pr comment` still has to /// name a round index for it. An array that is present but *empty* is the /// same situation reached from the other side, so it answers one too. /// /// This existed twice and the two disagreed on exactly that empty array — /// one answering `0`, one `1` — so a pull could be described as having no /// rounds by the reader and round 1 by the reviewer. pub(crate) fn round_count(value: &serde_json::Value) -> usize { value["rounds"].as_array().map_or(1, Vec::len).max(1) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn target_branch_prefers_the_modern_field() { let v = json!({"target": {"branch": "trunk"}, "targetBranch": "old"}); assert_eq!(target_branch(&v, "x").unwrap(), "trunk"); } #[test] fn target_branch_falls_back_to_the_legacy_field() { let v = json!({"targetBranch": "old"}); assert_eq!(target_branch(&v, "x").unwrap(), "old"); } #[test] fn target_branch_refuses_rather_than_guessing_main() { let err = target_branch(&json!({}), "at://x/y/z") .unwrap_err() .to_string(); assert!(err.contains("at://x/y/z"), "{err}"); assert!( !err.contains("main"), "it must not reach for a default: {err}" ); } #[test] fn target_branch_treats_an_empty_string_as_absent() { assert!(target_branch(&json!({"target": {"branch": ""}}), "x").is_err()); } #[test] fn a_record_with_no_rounds_array_is_one_round() { assert_eq!(round_count(&json!({"patch": "diff"})), 1); } #[test] fn an_empty_rounds_array_is_also_one_round() { assert_eq!(round_count(&json!({"rounds": []})), 1); } #[test] fn rounds_are_counted_when_present() { assert_eq!(round_count(&json!({"rounds": [1, 2, 3]})), 3); } } /// Where a pull record keeps the bytes of its latest patch. /// /// Two shapes, both real. A modern record carries `rounds[]`, each round /// naming a gzipped blob; a record written before that field existed carries /// the patch inline as a string, which is the shape Tangled's own backfill /// produced and which `pr view` has always read. /// /// **The two readers of this disagreed.** `pr`'s handled the inline shape; /// the one every `stack` path uses bailed `"has no rounds; nothing to read a /// patch from"` on exactly that record — so a pre-rounds pull could be /// viewed and diffed but neither merged nor counted, and /// [`round_count`] called it one round while the reader called it none. #[derive(Debug, PartialEq, Eq)] pub(crate) enum PatchBytes { /// A pre-rounds record: the patch is right there, uncompressed. Inline(String), /// A round's blob, to be fetched from the *author's* PDS and gunzipped. Blob { cid: String, size: u64 }, } /// The latest patch of a pull, wherever that record keeps it. /// /// `named` identifies the record in a refusal, since a caller reading a /// stack has several and "a pull" names none of them. /// /// An empty `rounds` array is the one case with no answer: [`round_count`] /// reports it as one round because a reader still has to name an index, but /// there are no bytes behind that index and saying so is the only honest /// reply. pub(crate) fn latest_patch(value: &serde_json::Value, named: &str) -> Result { if let Some(rounds) = value["rounds"].as_array().filter(|r| !r.is_empty()) { let round = rounds.last().expect("checked non-empty"); let cid = round["patchBlob"]["ref"]["$link"] .as_str() .ok_or_else(|| anyhow::anyhow!("{named}'s latest round has no patch blob reference"))?; return Ok(PatchBytes::Blob { cid: cid.to_string(), size: round["patchBlob"]["size"].as_u64().unwrap_or(0), }); } if let Some(patch) = value["patch"].as_str() { return Ok(PatchBytes::Inline(patch.to_string())); } anyhow::bail!("{named} has neither a round with a patch blob nor an inline patch") } #[cfg(test)] mod patch_tests { use super::*; use serde_json::json; #[test] fn the_last_round_is_the_latest_patch() { let v = json!({"rounds": [ {"patchBlob": {"ref": {"$link": "cid1"}, "size": 10}}, {"patchBlob": {"ref": {"$link": "cid2"}, "size": 20}}, ]}); assert_eq!( latest_patch(&v, "x").unwrap(), PatchBytes::Blob { cid: "cid2".to_string(), size: 20 } ); } #[test] fn a_pre_rounds_record_carries_its_patch_inline() { // The case the stack reader used to refuse outright. let v = json!({"patch": "diff --git a/x b/x\n"}); assert_eq!( latest_patch(&v, "x").unwrap(), PatchBytes::Inline("diff --git a/x b/x\n".to_string()) ); } #[test] fn an_empty_rounds_array_falls_through_to_the_inline_patch() { let v = json!({"rounds": [], "patch": "inline\n"}); assert_eq!( latest_patch(&v, "x").unwrap(), PatchBytes::Inline("inline\n".to_string()) ); } #[test] fn a_record_with_neither_is_refused_by_name() { let err = latest_patch(&json!({"rounds": []}), "at://x/y/z") .unwrap_err() .to_string(); assert!(err.contains("at://x/y/z"), "{err}"); } #[test] fn a_round_with_no_blob_reference_is_refused() { let v = json!({"rounds": [{"patchBlob": {"size": 1}}]}); assert!(latest_patch(&v, "x").is_err()); } }