#![allow(clippy::too_many_arguments)] use std::collections::HashMap; use std::fmt; use std::ops::Deref; use std::ops::Range; use std::path::PathBuf; use std::str::FromStr; use once_cell::sync::Lazy; use serde::ser::SerializeStruct; use serde::{Deserialize, Serialize}; use thiserror::Error; use radicle_crdt::clock; use radicle_crdt::{ GMap, GSet, Immutable, LWWMap, LWWReg, LWWSet, Lamport, Max, Redactable, Semilattice, }; use crate::cob; use crate::cob::common::{Author, Tag, Timestamp}; use crate::cob::store::Transaction; use crate::cob::store::{FromHistory as _, HistoryAction}; use crate::cob::thread; use crate::cob::thread::CommentId; use crate::cob::thread::Thread; use crate::cob::{store, ActorId, EntryId, ObjectId, TypeName}; use crate::crypto::{PublicKey, Signer}; use crate::git; use crate::identity; use crate::identity::doc::DocError; use crate::identity::PayloadError; use crate::prelude::*; /// The logical clock we use to order operations to patches. pub use clock::Lamport as Clock; /// Type name of a patch. pub static TYPENAME: Lazy = Lazy::new(|| FromStr::from_str("xyz.radicle.patch").expect("type name is valid")); /// Patch operation. pub type Op = cob::Op; /// Identifier for a patch. pub type PatchId = ObjectId; /// Unique identifier for a patch revision. pub type RevisionId = EntryId; /// Index of a revision in the revisions list. pub type RevisionIx = usize; /// Error applying an operation onto a state. #[derive(Debug, Error)] pub enum Error { /// Error trying to delete the protected root revision. #[error("refusing to delete root revision: {0}")] RootRevision(RevisionId), /// Causal dependency missing. /// /// This error indicates that the operations are not being applied /// in causal order, which is a requirement for this CRDT. /// /// For example, this can occur if an operation references anothern operation /// that hasn't happened yet. #[error("causal dependency {0:?} missing")] Missing(EntryId), /// Error applying an op to the patch thread. #[error("thread apply failed: {0}")] Thread(#[from] thread::Error), /// Error loading the identity document committed to by an operation. #[error("identity doc failed to load: {0}")] Doc(#[from] DocError), /// Error loading the document payload. #[error("payload failed to load: {0}")] Payload(#[from] PayloadError), /// The merge operation is invalid. #[error("invalid merge operation in {0}")] InvalidMerge(EntryId), /// Git error. #[error("git: {0}")] Git(#[from] git::ext::Error), /// Validation error. #[error("validation failed: {0}")] Validate(&'static str), /// Store error. #[error("store: {0}")] Store(#[from] store::Error), } /// Patch operation. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum Action { Edit { title: String, target: MergeTarget, }, EditRevision { revision: RevisionId, description: String, }, EditReview { review: EntryId, summary: Option, }, EditCodeComment { review: EntryId, comment: EntryId, body: String, }, Tag { add: Vec, remove: Vec, }, Revision { description: String, base: git::Oid, oid: git::Oid, }, Lifecycle { state: State, }, Redact { revision: RevisionId, }, Review { revision: RevisionId, summary: Option, verdict: Option, }, CodeComment { review: EntryId, body: String, location: CodeLocation, }, Merge { revision: RevisionId, commit: git::Oid, }, Thread { revision: RevisionId, action: thread::Action, }, } impl HistoryAction for Action { fn parents(&self) -> Vec { match self { Self::Revision { base, oid, .. } => { vec![*base, *oid] } Self::Merge { commit, .. } => { vec![*commit] } _ => vec![], } } } /// Where a patch is intended to be merged. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MergeTarget { /// Intended for the default branch of the project delegates. /// Note that if the delegations change while the patch is open, /// this will always mean whatever the "current" delegation set is. /// If it were otherwise, patches could become un-mergeable. #[default] Delegates, } impl MergeTarget { /// Get the head of the target branch. pub fn head(&self, repo: &R) -> Result { match self { MergeTarget::Delegates => { let (_, target) = repo.head()?; Ok(target) } } } } /// Patch CRDT. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Patch { /// Title of the patch. title: LWWReg>, /// Current state of the patch. state: LWWReg>, /// Target this patch is meant to be merged in. target: LWWReg>, /// Associated tags. /// Tags can be added and removed at will. tags: LWWSet, /// Patch merges. /// /// Only one merge is allowed per user. /// /// Merges can be removed and replaced, but not modified. Generally, once a revision is merged, /// it stays that way. Being able to remove merges may be useful in case of force updates /// on the target branch. merges: LWWMap>, /// List of patch revisions. The initial changeset is part of the /// first revision. /// /// Revisions can be redacted, but are otherwise immutable. revisions: GMap>, /// Users assigned to review this patch. reviewers: LWWSet, /// Timeline of operations. timeline: GSet<(Lamport, EntryId)>, /// Reviews index. Keeps track of reviews for better performance. reviews: GMap>, } impl Semilattice for Patch { fn merge(&mut self, other: Self) { self.title.merge(other.title); self.state.merge(other.state); self.target.merge(other.target); self.merges.merge(other.merges); self.tags.merge(other.tags); self.revisions.merge(other.revisions); self.reviewers.merge(other.reviewers); self.timeline.merge(other.timeline); self.reviews.merge(other.reviews); } } impl Default for Patch { fn default() -> Self { Self { title: LWWReg::initial(Max::from(String::default())), state: LWWReg::initial(Max::from(State::default())), target: LWWReg::initial(Max::from(MergeTarget::default())), tags: LWWSet::default(), merges: LWWMap::default(), revisions: GMap::default(), reviewers: LWWSet::default(), timeline: GSet::default(), reviews: GMap::default(), } } } impl Patch { /// Title of the patch. pub fn title(&self) -> &str { self.title.get().get() } /// Current state of the patch. pub fn state(&self) -> &State { self.state.get().get() } /// Target this patch is meant to be merged in. pub fn target(&self) -> MergeTarget { *self.target.get().get() } /// Timestamp of the first revision of the patch. pub fn timestamp(&self) -> Timestamp { self.revisions() .next() .map(|(_, r)| r) .expect("Patch::timestamp: at least one revision is present") .timestamp } /// Associated tags. pub fn tags(&self) -> impl Iterator { self.tags.iter() } /// Patch description. pub fn description(&self) -> &str { self.root().description() } /// Author of the first revision of the patch. pub fn author(&self) -> &Author { &self .revisions() .next() .map(|(_, r)| r) .expect("Patch::author: at least one revision is present") .author } /// Get the `Revision` by its `RevisionId`. /// /// None is returned if the `Revision` has been redacted (deleted). pub fn revision(&self, id: &RevisionId) -> Option<&Revision> { self.revisions.get(id).and_then(Redactable::get) } /// List of patch revisions. The initial changeset is part of the /// first revision. pub fn revisions(&self) -> impl DoubleEndedIterator { self.timeline.iter().filter_map(|(_, id)| { self.revisions .get(id) .and_then(Redactable::get) .map(|rev| (id, rev)) }) } /// List of patch reviewers. pub fn reviewers(&self) -> impl Iterator + '_ { self.reviewers.iter().map(Did::from) } /// Get the merges. pub fn merges(&self) -> impl Iterator { self.merges.iter().map(|(a, m)| (a, m.deref())) } /// Reference to the Git object containing the code on the latest revision. pub fn head(&self) -> &git::Oid { &self.latest().1.oid } /// Get the commit of the target branch on which this patch is based. /// This can change via a patch update. pub fn base(&self) -> &git::Oid { &self.latest().1.base } /// Get the merge base of this patch. pub fn merge_base(&self, repo: &R) -> Result { repo.merge_base(self.base(), self.head()) } /// Get the commit range of this patch. pub fn range( &self, repo: &R, ) -> Result<(git::Oid, git::Oid), git::ext::Error> { if self.is_merged() { Ok((*self.base(), *self.head())) } else { Ok((self.merge_base(repo)?, *self.head())) } } /// Index of latest revision in the revisions list. pub fn version(&self) -> RevisionIx { self.revisions .len() .checked_sub(1) .expect("Patch::version: at least one revision is present") } /// Root revision. /// /// This is the revision that was created with the patch. pub fn root(&self) -> &Revision { self.revisions() .next() .map(|(_, r)| r) .expect("Patch::root: there is always a root revision") } /// Latest revision. pub fn latest(&self) -> (&RevisionId, &Revision) { self.revisions() .next_back() .expect("Patch::latest: there is always at least one revision") } /// Time of last update. pub fn updated_at(&self) -> Timestamp { self.latest().1.timestamp() } /// Check if the patch is merged. pub fn is_merged(&self) -> bool { matches!(self.state(), State::Merged { .. }) } /// Check if the patch is open. pub fn is_open(&self) -> bool { matches!(self.state(), State::Open { .. }) } /// Check if the patch is archived. pub fn is_archived(&self) -> bool { matches!(self.state(), State::Archived) } /// Check if the patch is a draft. pub fn is_draft(&self) -> bool { matches!(self.state(), State::Draft) } } impl store::FromHistory for Patch { type Action = Action; type Error = Error; fn type_name() -> &'static TypeName { &*TYPENAME } fn validate(&self) -> Result<(), Self::Error> { if self.revisions.is_empty() { return Err(Error::Validate("no revisions found")); } if self.title().is_empty() { return Err(Error::Validate("empty title")); } Ok(()) } fn apply( &mut self, ops: impl IntoIterator, repo: &R, ) -> Result<(), Error> { for op in ops { let id = op.id; let author = Author::new(op.author); let timestamp = op.timestamp; self.timeline.insert((op.clock, id)); match op.action { Action::Edit { title, target } => { self.title.set(title, op.clock); self.target.set(target, op.clock); } Action::Lifecycle { state } => { self.state.set(state, op.clock); } Action::Tag { add, remove } => { for tag in add { self.tags.insert(tag, op.clock); } for tag in remove { self.tags.remove(tag, op.clock); } } Action::EditRevision { revision, description, } => { if let Some(redactable) = self.revisions.get_mut(&revision) { // If the revision was redacted concurrently, there's nothing to do. if let Redactable::Present(revision) = redactable { revision.description.set(description, op.clock); } } else { return Err(Error::Missing(revision)); } } Action::EditReview { review, summary } => { let Some(Redactable::Present((revision, author))) = self.reviews.get(&review) else { return Err(Error::Missing(review)); }; let Some(rev) = self.revisions.get_mut(revision) else { return Err(Error::Missing(*revision)); }; // If the revision was redacted concurrently, there's nothing to do. // Likewise, if the review was redacted concurrently, there's nothing to do. if let Some(rev) = rev.get_mut() { let Some(review) = rev.reviews.get_mut(author) else { return Err(Error::Missing(review)); }; if let Redactable::Present(review) = review { review.summary.set(summary.map(Max::from), op.clock); } } } Action::Revision { description, base, oid, } => { self.revisions.insert( id, Redactable::Present(Revision::new( author, description, base, oid, timestamp, op.clock, )), ); } Action::Redact { revision } => { // Redactions must have observed a revision to be valid. if let Some(revision) = self.revisions.get_mut(&revision) { revision.merge(Redactable::Redacted); } else { return Err(Error::Missing(revision)); } } Action::Review { revision, ref summary, verdict, } => { let Some(rev) = self.revisions.get_mut(&revision) else { return Err(Error::Missing(revision)); }; if let Some(rev) = rev.get_mut() { // Nb. Applying two reviews by the same author is not allowed and // results in the review being redacted. rev.reviews.insert( op.author, Redactable::Present(Review::new( verdict, summary.to_owned(), timestamp, op.clock, )), ); // Update reviews index. self.reviews .insert(op.id, Redactable::Present((revision, op.author))); } } Action::EditCodeComment { review, comment, body, } => { let Some(Redactable::Present((revision, author))) = self.reviews.get(&review) else { return Err(Error::Missing(review)); }; let Some(rev) = self.revisions.get_mut(revision) else { return Err(Error::Missing(*revision)); }; // If the revision was redacted concurrently, there's nothing to do. // Likewise, if the review was redacted concurrently, there's nothing to do. if let Some(rev) = rev.get_mut() { let Some(review) = rev.reviews.get_mut(author) else { return Err(Error::Missing(review)); }; if let Some(review) = review.get_mut() { let Some(comment) = review.comments.get_mut(&comment) else { return Err(Error::Missing(comment)); }; if let Some(comment) = comment.get_mut() { comment.edit(op.clock, body, timestamp); } } } } Action::CodeComment { review, body, location, } => { let Some(Redactable::Present((revision, author))) = self.reviews.get(&review) else { return Err(Error::Missing(review)); }; let Some(rev) = self.revisions.get_mut(revision) else { return Err(Error::Missing(*revision)); }; // If the revision was redacted concurrently, there's nothing to do. // Likewise, if the review was redacted concurrently, there's nothing to do. if let Some(rev) = rev.get_mut() { let Some(review) = rev.reviews.get_mut(author) else { return Err(Error::Missing(review)); }; if let Redactable::Present(review) = review { review.comments.insert( id, Redactable::Present(CodeComment::new( op.author, body, location, timestamp, )), ); } } } Action::Merge { revision, commit } => { let Some(rev) = self.revisions.get_mut(&revision) else { return Err(Error::Missing(revision)); }; if rev.get().is_some() { let doc = repo.identity_doc_at(op.identity)?.verified()?; match self.target() { MergeTarget::Delegates => { if !doc.is_delegate(&op.author) { return Err(Error::InvalidMerge(op.id)); } let proj = doc.project()?; let branch = git::refs::branch(proj.default_branch()); // Nb. We don't return an error in case the merge commit is not an // ancestor of the default branch. The default branch can change // *after* the merge action is created, which is out of the control // of the merge author. We simply skip it, which allows archiving in // case of a rebase off the master branch, or a redaction of the // merge. let Ok(head) = repo.reference_oid(&op.author, &branch) else { continue; }; if commit != head && !repo.is_ancestor_of(commit, head)? { continue; } } } self.merges.insert( op.author, Immutable::new(Merge { revision, commit, timestamp, }), op.clock, ); let mut merges = self.merges.iter().fold( HashMap::<(RevisionId, git::Oid), usize>::new(), |mut acc, (_, merge)| { *acc.entry((merge.revision, merge.commit)).or_default() += 1; acc }, ); // Discard revisions that weren't merged by a threshold of delegates. merges.retain(|_, count| *count >= doc.threshold); match merges.into_keys().collect::>().as_slice() { [] => { // None of the revisions met the quorum. } [(revision, commit)] => { // Patch is merged. self.state.set( State::Merged { revision: *revision, commit: *commit, }, op.clock, ); } revisions => { // More than one revision met the quorum. self.state.set( State::Open { conflicts: revisions.to_vec(), }, op.clock, ); } } } } Action::Thread { revision, action } => { // TODO(cloudhead): Make sure we can deal with redacted revisions which are added // to out of order, like in the `Merge` case. if let Some(Redactable::Present(revision)) = self.revisions.get_mut(&revision) { revision.discussion.apply( [cob::Op::new( op.id, action, op.author, timestamp, op.clock, op.identity, )], repo, )?; } else { return Err(Error::Missing(revision)); } } } } Ok(()) } } /// A patch revision. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Revision { /// Author of the revision. author: Author, /// Revision description. description: LWWReg>, /// Base branch commit, used as a merge base. base: git::Oid, /// Reference to the Git object containing the code (revision head). oid: git::Oid, /// Discussion around this revision. discussion: Thread, /// Reviews of this revision's changes (one per actor). reviews: GMap>, /// When this revision was created. timestamp: Timestamp, } impl Revision { pub fn new( author: Author, description: String, base: git::Oid, oid: git::Oid, timestamp: Timestamp, clock: Clock, ) -> Self { Self { author, description: LWWReg::new(Max::from(description), clock), base, oid, discussion: Thread::default(), reviews: GMap::default(), timestamp, } } pub fn description(&self) -> &str { self.description.get() } /// Author of the revision. pub fn author(&self) -> &Author { &self.author } /// Base branch commit, used as a merge base. pub fn base(&self) -> &git::Oid { &self.base } /// Reference to the Git object containing the code (revision head). pub fn head(&self) -> git::Oid { self.oid } /// When this revision was created. pub fn timestamp(&self) -> Timestamp { self.timestamp } /// Discussion around this revision. pub fn discussion(&self) -> &Thread { &self.discussion } /// Reviews of this revision's changes (one per actor). pub fn reviews(&self) -> impl DoubleEndedIterator { self.reviews .iter() .filter_map(|(author, review)| review.get().map(|r| (author, r))) } /// Get a review by author. pub fn review(&self, author: &ActorId) -> Option<&Review> { self.reviews.get(author).and_then(Redactable::get) } } /// Patch state. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "status")] pub enum State { Draft, Open { /// Revisions that were merged and are conflicting. #[serde(skip_serializing_if = "Vec::is_empty")] #[serde(default)] conflicts: Vec<(RevisionId, git::Oid)>, }, Archived, Merged { /// The revision that was merged. revision: RevisionId, /// The commit in the target branch that contains the changes. commit: git::Oid, }, } impl Default for State { fn default() -> Self { Self::Open { conflicts: vec![] } } } impl fmt::Display for State { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Archived => write!(f, "archived"), Self::Draft => write!(f, "draft"), Self::Open { .. } => write!(f, "open"), Self::Merged { .. } => write!(f, "merged"), } } } /// A merged patch revision. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "camelCase")] pub struct Merge { /// Revision that was merged. pub revision: RevisionId, /// Base branch commit that contains the revision. pub commit: git::Oid, /// When this merge was performed. pub timestamp: Timestamp, } /// A patch review verdict. #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Verdict { /// Accept patch. Accept, /// Reject patch. Reject, } impl Semilattice for Verdict { fn merge(&mut self, other: Self) { if self == &Self::Accept && other == Self::Reject { *self = other; } } } impl fmt::Display for Verdict { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Accept => write!(f, "accept"), Self::Reject => write!(f, "reject"), } } } /// Code location, used for attaching comments to diffs. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CodeLocation { /// Path of file. pub path: PathBuf, /// Line range on old file. `None` for added files. pub old: Option>, /// Line range on new file. `None` for deleted files. pub new: Option>, } impl PartialOrd for CodeLocation { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for CodeLocation { fn cmp(&self, other: &Self) -> std::cmp::Ordering { (&self.path, &self.old.as_ref().map(|o| (o.start, o.end))) .cmp(&(&other.path, &other.new.as_ref().map(|o| (o.start, o.end)))) } } /// Comment on code diff. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CodeComment { /// Comment author. author: ActorId, /// Code location of the comment. location: CodeLocation, /// Comment edits. edits: GMap>, } impl Serialize for CodeComment { fn serialize(&self, serializer: S) -> Result where S: serde::ser::Serializer, { let mut state = serializer.serialize_struct("CodeComment", 3)?; state.serialize_field("author", &self.author())?; state.serialize_field("location", self.location())?; state.serialize_field("body", self.body())?; state.end() } } impl CodeComment { pub fn new( author: ActorId, body: String, location: CodeLocation, timestamp: Timestamp, ) -> Self { let edit = thread::Edit { body, timestamp }; Self { author, location, edits: GMap::singleton(Lamport::initial(), Max::from(edit)), } } /// Add an edit. pub fn edit(&mut self, clock: Lamport, body: String, timestamp: Timestamp) { self.edits .insert(clock, thread::Edit { body, timestamp }.into()) } /// Comment author. pub fn author(&self) -> ActorId { self.author } /// Get the comment location. pub fn location(&self) -> &CodeLocation { &self.location } /// Get the comment body. If there are multiple edits, gets the value at the latest edit. pub fn body(&self) -> &str { // SAFETY: There is always at least one edit. This is guaranteed by [`CodeComment::new`] // constructor. #[allow(clippy::unwrap_used)] self.edits.values().last().unwrap().get().body.as_str() } } /// A patch review on a revision. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Review { /// Review verdict. /// /// The verdict cannot be changed, since revisions are immutable. verdict: Option, /// Review summary. /// /// Can be edited or set to `None`. summary: LWWReg>>, /// Review inline code comments. comments: GMap>, /// Review timestamp. timestamp: Timestamp, } impl Serialize for Review { fn serialize(&self, serializer: S) -> Result where S: serde::ser::Serializer, { let mut state = serializer.serialize_struct("Review", 4)?; state.serialize_field("verdict", &self.verdict())?; state.serialize_field("summary", &self.summary())?; state.serialize_field( "comments", &self.comments().map(|(_, c)| c.body()).collect::>(), )?; state.serialize_field("timestamp", &self.timestamp())?; state.end() } } impl Review { pub fn new( verdict: Option, summary: Option, timestamp: Timestamp, clock: Clock, ) -> Self { Self { verdict, summary: LWWReg::new(summary.map(Max::from), clock), comments: GMap::default(), timestamp, } } /// Review verdict. pub fn verdict(&self) -> Option { self.verdict } /// Review inline code comments. pub fn comments(&self) -> impl Iterator { self.comments .iter() .filter_map(|(id, r)| r.get().map(|comment| (id, comment))) } /// Review general comment. pub fn summary(&self) -> Option<&str> { self.summary.get().as_ref().map(|m| m.get().as_str()) } /// Review timestamp. pub fn timestamp(&self) -> Timestamp { self.timestamp } } impl store::Transaction { pub fn edit(&mut self, title: impl ToString, target: MergeTarget) -> Result<(), store::Error> { self.push(Action::Edit { title: title.to_string(), target, }) } pub fn edit_revision( &mut self, revision: RevisionId, description: impl ToString, ) -> Result<(), store::Error> { self.push(Action::EditRevision { revision, description: description.to_string(), }) } pub fn edit_review( &mut self, review: EntryId, summary: Option, ) -> Result<(), store::Error> { self.push(Action::EditReview { review, summary }) } /// Redact the revision. pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> { self.push(Action::Redact { revision }) } /// Start a patch revision discussion. pub fn thread( &mut self, revision: RevisionId, body: S, ) -> Result<(), store::Error> { self.push(Action::Thread { revision, action: thread::Action::Comment { body: body.to_string(), reply_to: None, }, }) } /// Comment on a patch revision. pub fn comment( &mut self, revision: RevisionId, body: S, reply_to: Option, ) -> Result<(), store::Error> { self.push(Action::Thread { revision, action: thread::Action::Comment { body: body.to_string(), reply_to, }, }) } /// Comment on code. pub fn code_comment( &mut self, review: EntryId, body: S, location: CodeLocation, ) -> Result<(), store::Error> { self.push(Action::CodeComment { review, body: body.to_string(), location, }) } /// Edit comment on code. pub fn edit_code_comment( &mut self, review: EntryId, comment: EntryId, body: S, ) -> Result<(), store::Error> { self.push(Action::EditCodeComment { review, comment, body: body.to_string(), }) } /// Review a patch revision. pub fn review( &mut self, revision: RevisionId, verdict: Option, summary: Option, ) -> Result<(), store::Error> { self.push(Action::Review { revision, summary, verdict, }) } /// Merge a patch revision. pub fn merge(&mut self, revision: RevisionId, commit: git::Oid) -> Result<(), store::Error> { self.push(Action::Merge { revision, commit }) } /// Update a patch with a new revision. pub fn revision( &mut self, description: impl ToString, base: impl Into, oid: impl Into, ) -> Result<(), store::Error> { self.push(Action::Revision { description: description.to_string(), base: base.into(), oid: oid.into(), }) } /// Lifecycle a patch. pub fn lifecycle(&mut self, state: State) -> Result<(), store::Error> { self.push(Action::Lifecycle { state }) } /// Tag a patch. pub fn tag( &mut self, add: impl IntoIterator, remove: impl IntoIterator, ) -> Result<(), store::Error> { let add = add.into_iter().collect::>(); let remove = remove.into_iter().collect::>(); self.push(Action::Tag { add, remove }) } } pub struct PatchMut<'a, 'g, R> { pub id: ObjectId, patch: Patch, clock: clock::Lamport, store: &'g mut Patches<'a, R>, } impl<'a, 'g, R> PatchMut<'a, 'g, R> where R: ReadRepository + SignRepository + cob::Store, { pub fn new( id: ObjectId, patch: Patch, clock: clock::Lamport, store: &'g mut Patches<'a, R>, ) -> Self { Self { id, clock, patch, store, } } pub fn transaction( &mut self, message: &str, signer: &G, operations: F, ) -> Result where G: Signer, F: FnOnce(&mut Transaction) -> Result<(), store::Error>, { let mut tx = Transaction::new(*signer.public_key(), self.clock); operations(&mut tx)?; let (ops, clock, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?; self.patch.apply(ops, self.store.as_ref())?; self.clock = clock; Ok(commit) } /// Get the internal logical clock. pub fn clock(&self) -> &clock::Lamport { &self.clock } /// Edit patch metadata. pub fn edit( &mut self, title: String, target: MergeTarget, signer: &G, ) -> Result { self.transaction("Edit", signer, |tx| tx.edit(title, target)) } /// Edit revision metadata. pub fn edit_revision( &mut self, revision: RevisionId, description: String, signer: &G, ) -> Result { self.transaction("Edit revision", signer, |tx| { tx.edit_revision(revision, description) }) } /// Edit review. pub fn edit_review( &mut self, review: EntryId, summary: Option, signer: &G, ) -> Result { self.transaction("Edit review", signer, |tx| tx.edit_review(review, summary)) } /// Redact a revision. pub fn redact( &mut self, revision: RevisionId, signer: &G, ) -> Result { if revision == RevisionId::from(self.id) { return Err(Error::RootRevision(revision)); } self.transaction("Redact revision", signer, |tx| tx.redact(revision)) } /// Create a thread on a patch revision. pub fn thread( &mut self, revision: RevisionId, body: S, signer: &G, ) -> Result { self.transaction("Create thread", signer, |tx| tx.thread(revision, body)) } /// Comment on a patch revision. pub fn comment( &mut self, revision: RevisionId, body: S, reply_to: Option, signer: &G, ) -> Result { self.transaction("Comment", signer, |tx| tx.comment(revision, body, reply_to)) } /// Comment on a line of code as part of a review. pub fn code_comment( &mut self, review: EntryId, body: S, location: CodeLocation, signer: &G, ) -> Result { self.transaction("Code comment", signer, |tx| { tx.code_comment(review, body, location) }) } /// Edit comment on code. pub fn edit_code_comment( &mut self, review: EntryId, comment: EntryId, body: S, signer: &G, ) -> Result { self.transaction("Edit code comment", signer, |tx| { tx.edit_code_comment(review, comment, body) }) } /// Review a patch revision. pub fn review( &mut self, revision: RevisionId, verdict: Option, comment: Option, signer: &G, ) -> Result { self.transaction("Review", signer, |tx| tx.review(revision, verdict, comment)) } /// Merge a patch revision. pub fn merge( &mut self, revision: RevisionId, commit: git::Oid, signer: &G, ) -> Result { // TODO: Don't allow merging the same revision twice? self.transaction("Merge revision", signer, |tx| tx.merge(revision, commit)) } /// Update a patch with a new revision. pub fn update( &mut self, description: impl ToString, base: impl Into, oid: impl Into, signer: &G, ) -> Result { self.transaction("Add revision", signer, |tx| { tx.revision(description, base, oid) }) } /// Lifecycle a patch. pub fn lifecycle(&mut self, state: State, signer: &G) -> Result { self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state)) } /// Archive a patch. pub fn archive(&mut self, signer: &G) -> Result { self.lifecycle(State::Archived, signer) } /// Mark a patch as ready to be reviewed. Returns `false` if the patch was not a draft. pub fn ready(&mut self, signer: &G) -> Result { if !self.is_draft() { return Ok(false); } self.lifecycle(State::Open { conflicts: vec![] }, signer)?; Ok(true) } /// Mark an open patch as a draft. Returns `false` if the patch was not open and free of merges. pub fn unready(&mut self, signer: &G) -> Result { if !matches!(self.state(), State::Open { conflicts } if conflicts.is_empty()) { return Ok(false); } self.lifecycle(State::Draft, signer)?; Ok(true) } /// Tag a patch. pub fn tag( &mut self, add: impl IntoIterator, remove: impl IntoIterator, signer: &G, ) -> Result { self.transaction("Tag", signer, |tx| tx.tag(add, remove)) } } impl<'a, 'g, R> Deref for PatchMut<'a, 'g, R> { type Target = Patch; fn deref(&self) -> &Self::Target { &self.patch } } /// Detailed information on patch states #[derive(Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct PatchCounts { pub open: usize, pub draft: usize, pub archived: usize, pub merged: usize, } pub struct Patches<'a, R> { raw: store::Store<'a, Patch, R>, } impl<'a, R> Deref for Patches<'a, R> { type Target = store::Store<'a, Patch, R>; fn deref(&self) -> &Self::Target { &self.raw } } impl<'a, R> Patches<'a, R> where R: ReadRepository + cob::Store, { /// Open an patches store. pub fn open(repository: &'a R) -> Result { let raw = store::Store::open(repository)?; Ok(Self { raw }) } /// Patches count by state. pub fn counts(&self) -> Result { let all = self.all()?; let state_groups = all.filter_map(|s| s.ok()) .fold(PatchCounts::default(), |mut state, (_, p, _)| { match p.state() { State::Draft => state.draft += 1, State::Open { .. } => state.open += 1, State::Archived => state.archived += 1, State::Merged { .. } => state.merged += 1, } state }); Ok(state_groups) } /// Find the `Patch` containing the given `Revision`. pub fn find_by_revision( &self, id: &RevisionId, ) -> Result, Error> { // Revision may be the patch's first, making it have the same ID. let p_id = ObjectId::from(id); if let Some(p) = self.get(&p_id)? { return Ok(p.revision(id).map(|r| (p_id, p.clone(), r.clone()))); } let result = self .all()? .filter_map(|result| result.ok()) .find_map(|(p_id, p, _)| p.revision(id).map(|r| (p_id, p.clone(), r.clone()))); Ok(result) } /// Get a patch. pub fn get(&self, id: &ObjectId) -> Result, store::Error> { self.raw.get(id).map(|r| r.map(|(p, _)| p)) } /// Get proposed patches. pub fn proposed( &self, ) -> Result + '_, Error> { let all = self.all()?; Ok(all .into_iter() .filter_map(|result| result.ok()) .filter(|(_, p, _)| p.is_open())) } /// Get patches proposed by the given key. pub fn proposed_by<'b>( &'b self, who: &'b Did, ) -> Result + '_, Error> { Ok(self .proposed()? .filter(move |(_, p, _)| p.author().id() == who)) } } impl<'a, R> Patches<'a, R> where R: ReadRepository + SignRepository + cob::Store, { /// Open a new patch. pub fn create<'g, G: Signer>( &'g mut self, title: impl ToString, description: impl ToString, target: MergeTarget, base: impl Into, oid: impl Into, tags: &[Tag], signer: &G, ) -> Result, Error> { self._create( title, description, target, base, oid, tags, State::default(), signer, ) } /// Draft a patch. This patch will be created in a [`State::Draft`] state. pub fn draft<'g, G: Signer>( &'g mut self, title: impl ToString, description: impl ToString, target: MergeTarget, base: impl Into, oid: impl Into, tags: &[Tag], signer: &G, ) -> Result, Error> { self._create( title, description, target, base, oid, tags, State::Draft, signer, ) } /// Get a patch mutably. pub fn get_mut<'g>(&'g mut self, id: &ObjectId) -> Result, store::Error> { let (patch, clock) = self .raw .get(id)? .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?; Ok(PatchMut { id: *id, clock, patch, store: self, }) } /// Create a patch. This is an internal function used by `create` and `draft`. fn _create<'g, G: Signer>( &'g mut self, title: impl ToString, description: impl ToString, target: MergeTarget, base: impl Into, oid: impl Into, tags: &[Tag], state: State, signer: &G, ) -> Result, Error> { let (id, patch, clock) = Transaction::initial("Create patch", &mut self.raw, signer, |tx| { tx.revision(description, base, oid)?; tx.edit(title, target)?; tx.tag(tags.to_owned(), [])?; if state != State::default() { tx.lifecycle(state)?; } Ok(()) })?; // Just a sanity check that our clock is advancing as expected. debug_assert_eq!(clock.get(), 1); Ok(PatchMut::new(id, patch, clock, self)) } } #[cfg(test)] mod test { use std::str::FromStr; use std::{array, iter}; use radicle_crdt::test::{assert_laws, WeightedGenerator}; use nonempty::nonempty; use pretty_assertions::assert_eq; use qcheck::{Arbitrary, TestResult}; use super::*; use crate::assert_matches; use crate::cob::test::Actor; use crate::crypto::test::signer::MockSigner; use crate::test; use crate::test::arbitrary; use crate::test::arbitrary::gen; use crate::test::storage::MockRepository; #[derive(Clone)] struct Changes { permutations: [Vec; N], } impl std::fmt::Debug for Changes { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { for (i, p) in self.permutations.iter().enumerate() { writeln!( f, "{i}: {:#?}", p.iter().map(|c| &c.action).collect::>() )?; } Ok(()) } } impl Arbitrary for Changes { fn arbitrary(g: &mut qcheck::Gen) -> Self { type State = (Actor, clock::Lamport, Vec, Vec); let rng = fastrand::Rng::with_seed(u64::arbitrary(g)); let oids = iter::repeat_with(|| { git::Oid::try_from( iter::repeat_with(|| rng.u8(..)) .take(20) .collect::>() .as_slice(), ) .unwrap() }) .take(16) .collect::>(); let gen = WeightedGenerator::<(clock::Lamport, Op), State>::new(rng.clone()) .variant(1, |(actor, clock, _, _), rng| { Some(( clock.tick(), actor.op(Action::Edit { title: iter::repeat_with(|| rng.alphabetic()).take(8).collect(), target: MergeTarget::Delegates, }), )) }) .variant(1, |(actor, clock, revisions, _), rng| { if revisions.is_empty() { return None; } let revision = revisions[rng.usize(..revisions.len())]; let commit = oids[rng.usize(..oids.len())]; Some((clock.tick(), actor.op(Action::Merge { revision, commit }))) }) .variant(1, |(actor, clock, revisions, _), rng| { if revisions.is_empty() { return None; } let revision = revisions[rng.usize(..revisions.len())]; Some((clock.tick(), actor.op(Action::Redact { revision }))) }) .variant(1, |(actor, clock, _, tags), rng| { let add = iter::repeat_with(|| rng.alphabetic()) .take(rng.usize(0..=3)) .map(|c| Tag::new(c).unwrap()) .collect::>(); let remove = tags .iter() .take(rng.usize(0..=tags.len())) .cloned() .collect(); for tag in &add { tags.push(tag.clone()); } Some((clock.tick(), actor.op(Action::Tag { add, remove }))) }) .variant(1, |(actor, clock, revisions, _), rng| { let oid = oids[rng.usize(..oids.len())]; let base = oids[rng.usize(..oids.len())]; let description = iter::repeat_with(|| rng.alphabetic()).take(6).collect(); let op = actor.op(Action::Revision { description, base, oid, }); if rng.bool() { revisions.push(op.id); } Some((*clock, op)) }); let mut changes = Vec::new(); let mut permutations: [Vec; N] = array::from_fn(|_| Vec::new()); for (_, op) in gen.take(g.size()) { changes.push(op); } for p in &mut permutations { *p = changes.clone(); rng.shuffle(&mut changes); } Changes { permutations } } } #[test] fn prop_invariants() { fn property(repo: MockRepository, log: Changes<3>) -> TestResult { let t = Patch::default(); let [p1, p2, p3] = log.permutations; let mut t1 = t.clone(); if t1.apply(p1, &repo).is_err() { return TestResult::discard(); } let mut t2 = t.clone(); if t2.apply(p2, &repo).is_err() { return TestResult::discard(); } let mut t3 = t; if t3.apply(p3, &repo).is_err() { return TestResult::discard(); } assert_eq!(t1, t2); assert_eq!(t2, t3); assert_laws(&t1, &t2, &t3); TestResult::passed() } qcheck::QuickCheck::new() .min_tests_passed(100) .gen(qcheck::Gen::new(7)) .quickcheck(property as fn(MockRepository, Changes<3>) -> TestResult); } #[test] fn test_json_serialization() { let edit = Action::Tag { add: vec![], remove: vec![], }; assert_eq!( serde_json::to_string(&edit).unwrap(), String::from(r#"{"type":"tag","add":[],"remove":[]}"#) ); } #[test] fn test_patch_create_and_get() { let tmp = tempfile::tempdir().unwrap(); let ctx = test::setup::Context::new(&tmp); let signer = &ctx.signer; let pr = ctx.branch_with(test::setup::initial_blobs()); let mut patches = Patches::open(&ctx.project).unwrap(); let author: Did = signer.public_key().into(); let target = MergeTarget::Delegates; let patch = patches .create( "My first patch", "Blah blah blah.", target, pr.base, pr.oid, &[], signer, ) .unwrap(); assert_eq!(patch.clock.get(), 1); let patch_id = patch.id; let patch = patches.get(&patch_id).unwrap().unwrap(); assert_eq!(patch.title(), "My first patch"); assert_eq!(patch.description(), "Blah blah blah."); assert_eq!(patch.author().id(), &author); assert_eq!(patch.state(), &State::Open { conflicts: vec![] }); assert_eq!(patch.target(), target); assert_eq!(patch.version(), 0); let (rev_id, revision) = patch.latest(); assert_eq!(revision.author.id(), &author); assert_eq!(revision.description(), "Blah blah blah."); assert_eq!(revision.discussion.len(), 0); assert_eq!(revision.oid, pr.oid); assert_eq!(revision.base, pr.base); let (id, _, _) = patches.find_by_revision(rev_id).unwrap().unwrap(); assert_eq!(id, patch_id); } #[test] fn test_patch_discussion() { let tmp = tempfile::tempdir().unwrap(); let ctx = test::setup::Context::new(&tmp); let signer = &ctx.signer; let pr = ctx.branch_with(test::setup::initial_blobs()); let mut patches = Patches::open(&ctx.project).unwrap(); let patch = patches .create( "My first patch", "Blah blah blah.", MergeTarget::Delegates, pr.base, pr.oid, &[], signer, ) .unwrap(); let id = patch.id; let mut patch = patches.get_mut(&id).unwrap(); let (revision_id, _) = patch.revisions().last().unwrap(); assert!( patch .comment(*revision_id, "patch comment", None, signer) .is_ok(), "can comment on patch" ); let (_, revision) = patch.revisions().last().unwrap(); let (_, comment) = revision.discussion.first().unwrap(); assert_eq!("patch comment", comment.body(), "comment body untouched"); } #[test] fn test_patch_merge() { let tmp = tempfile::tempdir().unwrap(); let ctx = test::setup::Context::new(&tmp); let signer = &ctx.signer; let pr = ctx.branch_with(test::setup::initial_blobs()); let mut patches = Patches::open(&ctx.project).unwrap(); let mut patch = patches .create( "My first patch", "Blah blah blah.", MergeTarget::Delegates, pr.base, pr.oid, &[], signer, ) .unwrap(); let id = patch.id; let (rid, _) = patch.revisions().next().unwrap(); let _merge = patch.merge(*rid, pr.base, signer).unwrap(); let patch = patches.get(&id).unwrap().unwrap(); let merges = patch.merges.iter().collect::>(); assert_eq!(merges.len(), 1); let (merger, merge) = merges.first().unwrap(); assert_eq!(*merger, signer.public_key()); assert_eq!(merge.commit, pr.base); } #[test] fn test_patch_merge_and_archive() { let rid = gen::(1); let base = git::Oid::from_str("d8711a8d43dc919fe39ae4b7c2f7b24667f5d470").unwrap(); let commit = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap(); let mut alice = Actor::::default(); let mut bob = Actor::::default(); let proj = gen::(1); let doc = Doc::new(proj, nonempty![alice.did(), bob.did()], 1) .verified() .unwrap(); let repo = MockRepository::new(rid, doc); let patch = alice .patch("Some changes", "", base, commit, &repo) .unwrap(); let (revision, _) = patch.revisions().next().unwrap(); // Create two concurrent operations. let clock = Lamport::from(2); let identity = repo.identity_head().unwrap(); let ops = [ alice.op_with( Action::Merge { revision: *revision, commit, }, clock, identity, ), bob.op_with( Action::Lifecycle { state: State::Archived, }, clock, identity, ), ]; let mut patch1 = patch.clone(); let mut patch2 = patch.clone(); // Apply the ops in different orders and expect the patch state to remain the same. patch1.apply(ops.iter().cloned(), &repo).unwrap(); patch2.apply(ops.iter().cloned().rev(), &repo).unwrap(); assert_matches!(patch1.state(), &State::Merged { .. }); assert_matches!(patch2.state(), &State::Merged { .. }); } #[test] fn test_patch_review() { let tmp = tempfile::tempdir().unwrap(); let ctx = test::setup::Context::new(&tmp); let signer = &ctx.signer; let pr = ctx.branch_with(test::setup::initial_blobs()); let mut patches = Patches::open(&ctx.project).unwrap(); let mut patch = patches .create( "My first patch", "Blah blah blah.", MergeTarget::Delegates, pr.base, pr.oid, &[], signer, ) .unwrap(); let (rid, _) = patch.latest(); patch .review(*rid, Some(Verdict::Accept), Some("LGTM".to_owned()), signer) .unwrap(); let id = patch.id; let patch = patches.get(&id).unwrap().unwrap(); let (_, revision) = patch.latest(); assert_eq!(revision.reviews.len(), 1); let review = revision.review(signer.public_key()).unwrap(); assert_eq!(review.verdict(), Some(Verdict::Accept)); assert_eq!(review.summary(), Some("LGTM")); } #[test] fn test_revision_review_merge_redacted() { let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap(); let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap(); let mut alice = Actor::new(MockSigner::default()); let mut patch = Patch::default(); let repo = gen::(1); let a1 = alice.op(Action::Revision { description: String::new(), base, oid, }); let a2 = alice.op(Action::Redact { revision: a1.id() }); let a3 = alice.op(Action::Review { revision: a1.id(), summary: None, verdict: Some(Verdict::Accept), }); let a4 = alice.op(Action::Merge { revision: a1.id(), commit: oid, }); patch.apply([a1], &repo).unwrap(); assert!(patch.revisions().next().is_some()); patch.apply([a2], &repo).unwrap(); assert!(patch.revisions().next().is_none()); patch.apply([a3], &repo).unwrap(); patch.apply([a4], &repo).unwrap(); } #[test] fn test_revision_edit_redact() { let base = arbitrary::oid(); let oid = arbitrary::oid(); let repo = gen::(1); let mut alice = Actor::new(MockSigner::default()); let mut p1 = Patch::default(); let mut p2 = Patch::default(); let a1 = alice.op(Action::Revision { description: String::new(), base, oid, }); let a2 = alice.op(Action::Redact { revision: a1.id }); let a3 = alice.op(Action::EditRevision { revision: a1.id, description: String::from("Edited"), }); p1.apply([a1.clone(), a2.clone(), a3.clone()], &repo) .unwrap(); p2.apply([a1, a3, a2], &repo).unwrap(); assert_eq!(p1, p2); } #[test] fn test_patch_review_edit() { let tmp = tempfile::tempdir().unwrap(); let ctx = test::setup::Context::new(&tmp); let signer = &ctx.signer; let pr = ctx.branch_with(test::setup::initial_blobs()); let mut patches = Patches::open(&ctx.project).unwrap(); let mut patch = patches .create( "My first patch", "Blah blah blah.", MergeTarget::Delegates, pr.base, pr.oid, &[], signer, ) .unwrap(); let (rid, _) = patch.latest(); let rid = *rid; let review = patch .review(rid, Some(Verdict::Accept), Some("LGTM".to_owned()), signer) .unwrap(); patch .edit_review(review, Some("Whoops!".to_owned()), signer) .unwrap(); // Overwrite the comment. // let (_, revision) = patch.latest(); let review = revision.review(signer.public_key()).unwrap(); assert_eq!(review.verdict(), Some(Verdict::Accept)); assert_eq!(review.summary(), Some("Whoops!")); } #[test] fn test_patch_review_comment() { let tmp = tempfile::tempdir().unwrap(); let ctx = test::setup::Context::new(&tmp); let signer = &ctx.signer; let pr = ctx.branch_with(test::setup::initial_blobs()); let mut patches = Patches::open(&ctx.project).unwrap(); let mut patch = patches .create( "My first patch", "Blah blah blah.", MergeTarget::Delegates, pr.base, pr.oid, &[], signer, ) .unwrap(); let (rid, _) = patch.latest(); let rid = *rid; let location = CodeLocation { path: PathBuf::from_str("README.md").unwrap(), old: None, new: Some(5..8), }; let review = patch.review(rid, None, None, signer).unwrap(); patch .code_comment( review, "I like these lines of code", location.clone(), signer, ) .unwrap(); let (_, revision) = patch.latest(); let review = revision.review(signer.public_key()).unwrap(); let (_, comment) = review.comments().next().unwrap(); assert_eq!(comment.body(), "I like these lines of code"); assert_eq!(comment.location(), &location); } #[test] fn test_patch_review_remove_summary() { let tmp = tempfile::tempdir().unwrap(); let ctx = test::setup::Context::new(&tmp); let signer = &ctx.signer; let pr = ctx.branch_with(test::setup::initial_blobs()); let mut patches = Patches::open(&ctx.project).unwrap(); let mut patch = patches .create( "My first patch", "Blah blah blah.", MergeTarget::Delegates, pr.base, pr.oid, &[], signer, ) .unwrap(); let (rid, _) = patch.latest(); let rid = *rid; let review = patch .review(rid, None, Some("Nah".to_owned()), signer) .unwrap(); patch.edit_review(review, None, signer).unwrap(); let id = patch.id; let patch = patches.get_mut(&id).unwrap(); let (_, revision) = patch.latest(); let review = revision.review(signer.public_key()).unwrap(); assert_eq!(review.summary(), None); } #[test] fn test_patch_update() { let tmp = tempfile::tempdir().unwrap(); let ctx = test::setup::Context::new(&tmp); let signer = &ctx.signer; let pr = ctx.branch_with(test::setup::initial_blobs()); let mut patches = Patches::open(&ctx.project).unwrap(); let mut patch = patches .create( "My first patch", "Blah blah blah.", MergeTarget::Delegates, pr.base, pr.oid, &[], signer, ) .unwrap(); assert_eq!(patch.clock.get(), 1); assert_eq!(patch.description(), "Blah blah blah."); assert_eq!(patch.version(), 0); let update = ctx.branch_with(test::setup::update_blobs()); let _ = patch .update("I've made changes.", pr.base, update.oid, signer) .unwrap(); assert_eq!(patch.clock.get(), 2); let id = patch.id; let patch = patches.get(&id).unwrap().unwrap(); assert_eq!(patch.version(), 1); assert_eq!(patch.revisions.len(), 2); assert_eq!(patch.revisions().count(), 2); assert_eq!( patch.revisions().nth(0).unwrap().1.description(), "Blah blah blah." ); assert_eq!( patch.revisions().nth(1).unwrap().1.description(), "I've made changes." ); let (_, revision) = patch.latest(); assert_eq!(patch.version(), 1); assert_eq!(revision.oid, update.oid); assert_eq!(revision.description(), "I've made changes."); } #[test] fn test_patch_redact() { let tmp = tempfile::tempdir().unwrap(); let ctx = test::setup::Context::new(&tmp); let signer = &ctx.signer; let pr = ctx.branch_with(test::setup::initial_blobs()); let mut patches = Patches::open(&ctx.project).unwrap(); let mut patch = patches .create( "My first patch", "Blah blah blah.", MergeTarget::Delegates, pr.base, pr.oid, &[], signer, ) .unwrap(); let patch_id = patch.id; let update = ctx.branch_with(test::setup::update_blobs()); let revision_id = patch .update("I've made changes.", pr.base, update.oid, signer) .unwrap(); assert_eq!(patch.revisions().count(), 2); patch.redact(revision_id, signer).unwrap(); assert_eq!(patch.latest().0, &RevisionId::from(patch_id)); assert_eq!(patch.revisions().count(), 1); // The patch's root must always exist. assert!(patch.redact(*patch.latest().0, signer).is_err()); } }