Skip to main content

radicle/cob/
patch.rs

1pub mod cache;
2
3mod actions;
4pub use actions::ReviewEdit;
5
6mod encoding;
7
8use std::collections::btree_map;
9use std::collections::{BTreeMap, BTreeSet, HashMap};
10use std::fmt;
11use std::ops::Deref;
12use std::str::FromStr;
13use std::sync::LazyLock;
14
15use amplify::Wrapper;
16use nonempty::NonEmpty;
17use serde::{Deserialize, Serialize};
18use storage::{HasRepoId, RepositoryError};
19use thiserror::Error;
20
21use crate::cob;
22use crate::cob::common::{Author, Authorization, CodeLocation, Label, Reaction, Timestamp};
23use crate::cob::store::Transaction;
24use crate::cob::store::{Cob, CobAction};
25use crate::cob::thread;
26use crate::cob::thread::Thread;
27use crate::cob::thread::{Comment, CommentId, Edit, Reactions};
28use crate::cob::{op, store, ActorId, Embed, EntryId, ObjectId, TypeName, Uri};
29use crate::crypto::PublicKey;
30use crate::git;
31use crate::identity::doc::{DocAt, DocError};
32use crate::identity::PayloadError;
33use crate::node::device::Device;
34use crate::prelude::*;
35use crate::storage;
36
37pub use cache::Cache;
38
39/// Type name of a patch.
40pub static TYPENAME: LazyLock<TypeName> =
41    LazyLock::new(|| FromStr::from_str("xyz.radicle.patch").expect("type name is valid"));
42
43/// Patch operation.
44pub type Op = cob::Op<Action>;
45
46/// Identifier for a patch.
47pub type PatchId = ObjectId;
48
49pub type PatchStream<'a> = cob::stream::Stream<'a, Action>;
50
51impl<'a> PatchStream<'a> {
52    pub fn init(patch: PatchId, store: &'a storage::git::Repository) -> Self {
53        let history = cob::stream::CobRange::new(&TYPENAME, &patch);
54        Self::new(&store.backend, history, TYPENAME.clone())
55    }
56}
57
58/// Unique identifier for a patch revision.
59#[derive(
60    Wrapper,
61    Debug,
62    Clone,
63    Copy,
64    Serialize,
65    Deserialize,
66    PartialEq,
67    Eq,
68    PartialOrd,
69    Ord,
70    Hash,
71    From,
72    Display,
73)]
74#[display(inner)]
75#[wrap(Deref)]
76pub struct RevisionId(EntryId);
77
78/// Unique identifier for a patch review.
79#[derive(
80    Wrapper,
81    Debug,
82    Clone,
83    Copy,
84    Serialize,
85    Deserialize,
86    PartialEq,
87    Eq,
88    PartialOrd,
89    Ord,
90    Hash,
91    From,
92    Display,
93)]
94#[display(inner)]
95#[wrapper(Deref)]
96pub struct ReviewId(EntryId);
97
98/// Index of a revision in the revisions list.
99pub type RevisionIx = usize;
100
101/// Error applying an operation onto a state.
102#[derive(Debug, Error)]
103pub enum Error {
104    /// Causal dependency missing.
105    ///
106    /// This error indicates that the operations are not being applied
107    /// in causal order, which is a requirement for this CRDT.
108    ///
109    /// For example, this can occur if an operation references another operation
110    /// that hasn't happened yet.
111    #[error("causal dependency {0:?} missing")]
112    Missing(EntryId),
113    /// Error applying an op to the patch thread.
114    #[error("thread apply failed: {0}")]
115    Thread(#[from] thread::Error),
116    /// Error loading the identity document committed to by an operation.
117    #[error("identity doc failed to load: {0}")]
118    Doc(#[from] DocError),
119    /// Identity document is missing.
120    #[error("missing identity document")]
121    MissingIdentity,
122    /// Review is empty.
123    #[error("empty review; verdict or summary not provided")]
124    EmptyReview,
125    /// Duplicate review.
126    #[error("review {0} of {1} already exists by author {2}")]
127    DuplicateReview(ReviewId, RevisionId, NodeId),
128    /// Error loading the document payload.
129    #[error("payload failed to load: {0}")]
130    Payload(#[from] PayloadError),
131    /// Git error.
132    #[error("git: {0}")]
133    Git(#[from] git::raw::Error),
134    /// Store error.
135    #[error("store: {0}")]
136    Store(#[from] store::Error),
137    #[error("op decoding failed: {0}")]
138    Op(#[from] op::OpEncodingError),
139    /// Action not authorized by the author
140    #[error("{0} not authorized to apply {1:?}")]
141    NotAuthorized(ActorId, Box<Action>),
142    /// An illegal action.
143    #[error("action is not allowed: {0}")]
144    NotAllowed(EntryId),
145    /// Revision not found.
146    #[error("revision not found: {0}")]
147    RevisionNotFound(RevisionId),
148    /// Initialization failed.
149    #[error("initialization failed: {0}")]
150    Init(&'static str),
151    #[error("failed to update patch {id} in cache: {err}")]
152    CacheUpdate {
153        id: PatchId,
154        #[source]
155        err: Box<dyn std::error::Error + Send + Sync + 'static>,
156    },
157    #[error("failed to remove patch {id} from cache: {err}")]
158    CacheRemove {
159        id: PatchId,
160        #[source]
161        err: Box<dyn std::error::Error + Send + Sync + 'static>,
162    },
163    #[error("failed to remove patches from cache: {err}")]
164    CacheRemoveAll {
165        #[source]
166        err: Box<dyn std::error::Error + Send + Sync + 'static>,
167    },
168}
169
170/// Patch operation.
171#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
172#[serde(tag = "type", rename_all = "camelCase")]
173pub enum Action {
174    //
175    // Actions on patch.
176    //
177    #[serde(rename = "edit")]
178    Edit {
179        title: cob::Title,
180        target: MergeTarget,
181    },
182    #[serde(rename = "label")]
183    Label { labels: BTreeSet<Label> },
184    #[serde(rename = "lifecycle")]
185    Lifecycle { state: Lifecycle },
186    #[serde(rename = "assign")]
187    Assign { assignees: BTreeSet<Did> },
188    #[serde(rename = "merge")]
189    Merge {
190        revision: RevisionId,
191        commit: git::Oid,
192    },
193
194    //
195    // Review actions
196    //
197    #[serde(rename = "review")]
198    Review {
199        revision: RevisionId,
200        #[serde(default, skip_serializing_if = "Option::is_none")]
201        summary: Option<String>,
202        #[serde(default, skip_serializing_if = "Option::is_none")]
203        verdict: Option<Verdict>,
204        #[serde(default, skip_serializing_if = "Vec::is_empty")]
205        labels: Vec<Label>,
206    },
207    #[serde(rename = "review.redact")]
208    ReviewRedact { review: ReviewId },
209    #[serde(rename = "review.comment")]
210    ReviewComment {
211        review: ReviewId,
212        body: String,
213        #[serde(default, skip_serializing_if = "Option::is_none")]
214        location: Option<CodeLocation>,
215        /// Comment this is a reply to.
216        /// Should be [`None`] if it's the first comment.
217        /// Should be [`Some`] otherwise.
218        #[serde(default, skip_serializing_if = "Option::is_none")]
219        reply_to: Option<CommentId>,
220        /// Embedded content.
221        #[serde(default, skip_serializing_if = "Vec::is_empty")]
222        embeds: Vec<Embed<Uri>>,
223    },
224    #[serde(rename = "review.comment.edit")]
225    ReviewCommentEdit {
226        review: ReviewId,
227        comment: EntryId,
228        body: String,
229        embeds: Vec<Embed<Uri>>,
230    },
231    #[serde(rename = "review.comment.redact")]
232    ReviewCommentRedact { review: ReviewId, comment: EntryId },
233    #[serde(rename = "review.comment.react")]
234    ReviewCommentReact {
235        review: ReviewId,
236        comment: EntryId,
237        reaction: Reaction,
238        active: bool,
239    },
240    #[serde(rename = "review.comment.resolve")]
241    ReviewCommentResolve { review: ReviewId, comment: EntryId },
242    #[serde(rename = "review.comment.unresolve")]
243    ReviewCommentUnresolve { review: ReviewId, comment: EntryId },
244    /// React to the review.
245    #[serde(rename = "review.react")]
246    ReviewReact {
247        review: ReviewId,
248        reaction: Reaction,
249        active: bool,
250    },
251
252    //
253    // Revision actions
254    //
255    #[serde(rename = "revision")]
256    Revision {
257        description: String,
258        base: git::Oid,
259        oid: git::Oid,
260        /// Review comments resolved by this revision.
261        #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
262        resolves: BTreeSet<(EntryId, CommentId)>,
263    },
264    #[serde(rename = "revision.edit")]
265    RevisionEdit {
266        revision: RevisionId,
267        description: String,
268        /// Embedded content.
269        #[serde(default, skip_serializing_if = "Vec::is_empty")]
270        embeds: Vec<Embed<Uri>>,
271    },
272    /// React to the revision.
273    #[serde(rename = "revision.react")]
274    RevisionReact {
275        revision: RevisionId,
276        #[serde(default, skip_serializing_if = "Option::is_none")]
277        location: Option<CodeLocation>,
278        reaction: Reaction,
279        active: bool,
280    },
281    #[serde(rename = "revision.redact")]
282    RevisionRedact { revision: RevisionId },
283    #[serde(rename_all = "camelCase")]
284    #[serde(rename = "revision.comment")]
285    RevisionComment {
286        /// The revision to comment on.
287        revision: RevisionId,
288        /// For comments on the revision code.
289        #[serde(default, skip_serializing_if = "Option::is_none")]
290        location: Option<CodeLocation>,
291        /// Comment body.
292        body: String,
293        /// Comment this is a reply to.
294        /// Should be [`None`] if it's the top-level comment.
295        /// Should be the root [`CommentId`] if it's a top-level comment.
296        #[serde(default, skip_serializing_if = "Option::is_none")]
297        reply_to: Option<CommentId>,
298        /// Embedded content.
299        #[serde(default, skip_serializing_if = "Vec::is_empty")]
300        embeds: Vec<Embed<Uri>>,
301    },
302    /// Edit a revision comment.
303    #[serde(rename = "revision.comment.edit")]
304    RevisionCommentEdit {
305        revision: RevisionId,
306        comment: CommentId,
307        body: String,
308        embeds: Vec<Embed<Uri>>,
309    },
310    /// Redact a revision comment.
311    #[serde(rename = "revision.comment.redact")]
312    RevisionCommentRedact {
313        revision: RevisionId,
314        comment: CommentId,
315    },
316    /// React to a revision comment.
317    #[serde(rename = "revision.comment.react")]
318    RevisionCommentReact {
319        revision: RevisionId,
320        comment: CommentId,
321        reaction: Reaction,
322        active: bool,
323    },
324    /// Edit a review's summary, verdict, labels, and embeds.
325    // Note that the tags live on `actions::ReviewEdit`, and according to the
326    // serde.rs docs, it must come after the other variants due to the
327    // `untagged` declaration.
328    #[serde(untagged)]
329    ReviewEdit(actions::ReviewEdit),
330}
331
332impl CobAction for Action {
333    fn parents(&self) -> Vec<git::Oid> {
334        match self {
335            Self::Revision { base, oid, .. } => {
336                vec![*base, *oid]
337            }
338            Self::Merge { commit, .. } => {
339                vec![*commit]
340            }
341            _ => vec![],
342        }
343    }
344
345    fn produces_identifier(&self) -> bool {
346        matches!(
347            self,
348            Self::Revision { .. }
349                | Self::RevisionComment { .. }
350                | Self::Review { .. }
351                | Self::ReviewComment { .. }
352        )
353    }
354}
355
356/// Output of a merge.
357#[derive(Debug)]
358#[must_use]
359pub struct Merged<'a, R> {
360    pub patch: PatchId,
361    pub entry: EntryId,
362
363    stored: &'a R,
364}
365
366impl<R: WriteRepository> Merged<'_, R> {
367    /// Cleanup after merging a patch.
368    ///
369    /// This removes Git refs relating to the patch, both in the working copy,
370    /// and the stored copy; and updates `rad/sigrefs`.
371    pub fn cleanup<G>(
372        self,
373        working: &git::raw::Repository,
374        signer: &Device<G>,
375    ) -> Result<(), storage::RepositoryError>
376    where
377        G: crypto::signature::Signer<crypto::Signature>,
378    {
379        let nid = signer.public_key();
380        let stored_ref = git::refs::patch(&self.patch).with_namespace(nid.into());
381        let working_ref = git::refs::workdir::patch_upstream(&self.patch);
382
383        working
384            .find_reference(&working_ref)
385            .and_then(|mut r| r.delete())
386            .ok();
387
388        self.stored
389            .raw()
390            .find_reference(&stored_ref)
391            .and_then(|mut r| r.delete())
392            .ok();
393        self.stored.sign_refs(signer)?;
394
395        Ok(())
396    }
397}
398
399/// Where a patch is intended to be merged.
400#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub enum MergeTarget {
403    /// Intended for the default branch of the project delegates.
404    /// Note that if the delegations change while the patch is open,
405    /// this will always mean whatever the "current" delegation set is.
406    /// If it were otherwise, patches could become un-mergeable.
407    #[default]
408    Delegates,
409}
410
411impl MergeTarget {
412    /// Get the head of the target branch.
413    pub fn head<R: ReadRepository>(&self, repo: &R) -> Result<git::Oid, RepositoryError> {
414        match self {
415            MergeTarget::Delegates => {
416                let (_, target) = repo.head()?;
417                Ok(target)
418            }
419        }
420    }
421}
422
423/// Patch state.
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct Patch {
427    /// Title of the patch.
428    pub(super) title: cob::Title,
429    /// Patch author.
430    pub(super) author: Author,
431    /// Current state of the patch.
432    pub(super) state: State,
433    /// Target this patch is meant to be merged in.
434    pub(super) target: MergeTarget,
435    /// Associated labels.
436    /// Labels can be added and removed at will.
437    pub(super) labels: BTreeSet<Label>,
438    /// Patch merges.
439    ///
440    /// Only one merge is allowed per user.
441    ///
442    /// Merges can be removed and replaced, but not modified. Generally, once a revision is merged,
443    /// it stays that way. Being able to remove merges may be useful in case of force updates
444    /// on the target branch.
445    pub(super) merges: BTreeMap<ActorId, Merge>,
446    /// List of patch revisions. The initial changeset is part of the
447    /// first revision.
448    ///
449    /// Revisions can be redacted, but are otherwise immutable.
450    pub(super) revisions: BTreeMap<RevisionId, Option<Revision>>,
451    /// Users assigned to review this patch.
452    pub(super) assignees: BTreeSet<ActorId>,
453    /// Timeline of operations.
454    pub(super) timeline: Vec<EntryId>,
455    /// Reviews index. Keeps track of reviews for better performance.
456    pub(super) reviews: BTreeMap<ReviewId, Option<(RevisionId, ActorId)>>,
457}
458
459impl Patch {
460    /// Construct a new patch object from a revision.
461    pub fn new(
462        title: cob::Title,
463        target: MergeTarget,
464        (id, revision): (RevisionId, Revision),
465    ) -> Self {
466        Self {
467            title,
468            author: revision.author.clone(),
469            state: State::default(),
470            target,
471            labels: BTreeSet::default(),
472            merges: BTreeMap::default(),
473            revisions: BTreeMap::from_iter([(id, Some(revision))]),
474            assignees: BTreeSet::default(),
475            timeline: vec![id.into_inner()],
476            reviews: BTreeMap::default(),
477        }
478    }
479
480    /// Title of the patch.
481    pub fn title(&self) -> &str {
482        self.title.as_ref()
483    }
484
485    /// Current state of the patch.
486    pub fn state(&self) -> &State {
487        &self.state
488    }
489
490    /// Target this patch is meant to be merged in.
491    pub fn target(&self) -> MergeTarget {
492        self.target
493    }
494
495    /// Timestamp of the first revision of the patch.
496    pub fn timestamp(&self) -> Timestamp {
497        self.updates()
498            .next()
499            .map(|(_, r)| r)
500            .expect("Patch::timestamp: at least one revision is present")
501            .timestamp
502    }
503
504    /// Associated labels.
505    pub fn labels(&self) -> impl Iterator<Item = &Label> {
506        self.labels.iter()
507    }
508
509    /// Patch description.
510    pub fn description(&self) -> &str {
511        let (_, r) = self.root();
512        r.description()
513    }
514
515    /// Patch embeds.
516    pub fn embeds(&self) -> &[Embed<Uri>] {
517        let (_, r) = self.root();
518        r.embeds()
519    }
520
521    /// Author of the first revision of the patch.
522    pub fn author(&self) -> &Author {
523        &self.author
524    }
525
526    /// All revision authors.
527    pub fn authors(&self) -> BTreeSet<&Author> {
528        self.revisions
529            .values()
530            .filter_map(|r| r.as_ref())
531            .map(|r| &r.author)
532            .collect()
533    }
534
535    /// Get the `Revision` by its `RevisionId`.
536    ///
537    /// None is returned if the `Revision` has been redacted (deleted).
538    pub fn revision(&self, id: &RevisionId) -> Option<&Revision> {
539        self.revisions.get(id).and_then(|o| o.as_ref())
540    }
541
542    /// List of patch revisions by the patch author. The initial changeset is part of the
543    /// first revision.
544    pub fn updates(&self) -> impl DoubleEndedIterator<Item = (RevisionId, &Revision)> {
545        self.revisions_by(self.author().public_key())
546    }
547
548    /// List of all patch revisions by all authors.
549    pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (RevisionId, &Revision)> {
550        self.timeline.iter().filter_map(move |id| {
551            self.revisions
552                .get(id)
553                .and_then(|o| o.as_ref())
554                .map(|rev| (RevisionId(*id), rev))
555        })
556    }
557
558    /// List of patch revisions by the given author.
559    pub fn revisions_by<'a>(
560        &'a self,
561        author: &'a PublicKey,
562    ) -> impl DoubleEndedIterator<Item = (RevisionId, &'a Revision)> {
563        self.revisions()
564            .filter(move |(_, r)| r.author.public_key() == author)
565    }
566
567    /// List of patch reviews of the given revision.
568    pub fn reviews_of(&self, rev: RevisionId) -> impl Iterator<Item = (&ReviewId, &Review)> {
569        self.reviews.iter().filter_map(move |(review_id, t)| {
570            t.and_then(|(rev_id, pk)| {
571                if rev == rev_id {
572                    self.revision(&rev_id)
573                        .and_then(|r| r.review_by(&pk))
574                        .map(|r| (review_id, r))
575                } else {
576                    None
577                }
578            })
579        })
580    }
581
582    /// List of patch assignees.
583    pub fn assignees(&self) -> impl Iterator<Item = Did> + '_ {
584        self.assignees.iter().map(Did::from)
585    }
586
587    /// Get the merges.
588    pub fn merges(&self) -> impl Iterator<Item = (&ActorId, &Merge)> {
589        self.merges.iter()
590    }
591
592    /// Reference to the Git object containing the code on the latest revision.
593    pub fn head(&self) -> &git::Oid {
594        &self.latest().1.oid
595    }
596
597    /// Get the commit of the target branch on which this patch is based.
598    /// This can change via a patch update.
599    pub fn base(&self) -> &git::Oid {
600        &self.latest().1.base
601    }
602
603    /// Get the merge base of this patch.
604    pub fn merge_base<R: ReadRepository>(
605        &self,
606        repo: &R,
607    ) -> Result<crate::git::Oid, crate::git::raw::Error> {
608        repo.merge_base(self.base(), self.head())
609    }
610
611    /// Get the commit range of this patch.
612    pub fn range(&self) -> Result<(crate::git::Oid, crate::git::Oid), crate::git::raw::Error> {
613        Ok((*self.base(), *self.head()))
614    }
615
616    /// Index of latest revision in the revisions list.
617    pub fn version(&self) -> RevisionIx {
618        self.revisions
619            .len()
620            .checked_sub(1)
621            .expect("Patch::version: at least one revision is present")
622    }
623
624    /// Root revision.
625    ///
626    /// This is the revision that was created with the patch.
627    pub fn root(&self) -> (RevisionId, &Revision) {
628        self.updates()
629            .next()
630            .expect("Patch::root: there is always a root revision")
631    }
632
633    /// Latest revision by the patch author.
634    pub fn latest(&self) -> (RevisionId, &Revision) {
635        self.latest_by(self.author().public_key())
636            .expect("Patch::latest: there is always at least one revision")
637    }
638
639    /// Latest revision by the given author.
640    pub fn latest_by<'a>(&'a self, author: &'a PublicKey) -> Option<(RevisionId, &'a Revision)> {
641        self.revisions_by(author).next_back()
642    }
643
644    /// Time of last update.
645    pub fn updated_at(&self) -> Timestamp {
646        self.latest().1.timestamp()
647    }
648
649    /// Check if the patch is merged.
650    pub fn is_merged(&self) -> bool {
651        matches!(self.state(), State::Merged { .. })
652    }
653
654    /// Check if the patch is open.
655    pub fn is_open(&self) -> bool {
656        matches!(self.state(), State::Open { .. })
657    }
658
659    /// Check if the patch is archived.
660    pub fn is_archived(&self) -> bool {
661        matches!(self.state(), State::Archived)
662    }
663
664    /// Check if the patch is a draft.
665    pub fn is_draft(&self) -> bool {
666        matches!(self.state(), State::Draft)
667    }
668
669    /// Apply authorization rules on patch actions.
670    pub fn authorization(
671        &self,
672        action: &Action,
673        actor: &ActorId,
674        doc: &Doc,
675    ) -> Result<Authorization, Error> {
676        if doc.is_delegate(&actor.into()) {
677            // A delegate is authorized to do all actions.
678            return Ok(Authorization::Allow);
679        }
680        let author = self.author().id().as_key();
681        let outcome = match action {
682            // The patch author can edit the patch and change its state.
683            Action::Edit { .. } => Authorization::from(actor == author),
684            Action::Lifecycle { state } => Authorization::from(match state {
685                Lifecycle::Open => actor == author,
686                Lifecycle::Draft => actor == author,
687                Lifecycle::Archived => actor == author,
688            }),
689            // Only delegates can carry out these actions.
690            Action::Label { labels } => {
691                if labels == &self.labels {
692                    // No-op is allowed for backwards compatibility.
693                    Authorization::Allow
694                } else {
695                    Authorization::Deny
696                }
697            }
698            Action::Assign { .. } => Authorization::Deny,
699            Action::Merge { .. } => match self.target() {
700                MergeTarget::Delegates => Authorization::Deny,
701            },
702            // Anyone can submit a review.
703            Action::Review { .. } => Authorization::Allow,
704            Action::ReviewRedact { review, .. } => {
705                if let Some((_, review)) = lookup::review(self, review)? {
706                    Authorization::from(actor == review.author.public_key())
707                } else {
708                    // Redacted.
709                    Authorization::Unknown
710                }
711            }
712            Action::ReviewEdit(edit) => {
713                if let Some((_, review)) = lookup::review(self, edit.review_id())? {
714                    Authorization::from(actor == review.author.public_key())
715                } else {
716                    // Redacted.
717                    Authorization::Unknown
718                }
719            }
720            // Anyone can comment on a review.
721            Action::ReviewComment { .. } => Authorization::Allow,
722            // The comment author can edit and redact their own comment.
723            Action::ReviewCommentEdit {
724                review, comment, ..
725            }
726            | Action::ReviewCommentRedact { review, comment } => {
727                if let Some((_, review)) = lookup::review(self, review)? {
728                    if let Some(comment) = review.comments.comment(comment) {
729                        return Ok(Authorization::from(*actor == comment.author()));
730                    }
731                }
732                // Redacted.
733                Authorization::Unknown
734            }
735            // Anyone can react to a review comment.
736            Action::ReviewCommentReact { .. } => Authorization::Allow,
737            // The reviewer, commenter or revision author can resolve and unresolve review comments.
738            Action::ReviewCommentResolve { review, comment }
739            | Action::ReviewCommentUnresolve { review, comment } => {
740                if let Some((revision, review)) = lookup::review(self, review)? {
741                    if let Some(comment) = review.comments.comment(comment) {
742                        return Ok(Authorization::from(
743                            actor == &comment.author()
744                                || actor == review.author.public_key()
745                                || actor == revision.author.public_key(),
746                        ));
747                    }
748                }
749                // Redacted.
750                Authorization::Unknown
751            }
752            Action::ReviewReact { .. } => Authorization::Allow,
753            // Anyone can propose revisions.
754            Action::Revision { .. } => Authorization::Allow,
755            // Only the revision author can edit or redact their revision.
756            Action::RevisionEdit { revision, .. } | Action::RevisionRedact { revision, .. } => {
757                if let Some(revision) = lookup::revision(self, revision)? {
758                    Authorization::from(actor == revision.author.public_key())
759                } else {
760                    // Redacted.
761                    Authorization::Unknown
762                }
763            }
764            // Anyone can react to or comment on a revision.
765            Action::RevisionReact { .. } => Authorization::Allow,
766            Action::RevisionComment { .. } => Authorization::Allow,
767            // Only the comment author can edit or redact their comment.
768            Action::RevisionCommentEdit {
769                revision, comment, ..
770            }
771            | Action::RevisionCommentRedact {
772                revision, comment, ..
773            } => {
774                if let Some(revision) = lookup::revision(self, revision)? {
775                    if let Some(comment) = revision.discussion.comment(comment) {
776                        return Ok(Authorization::from(actor == &comment.author()));
777                    }
778                }
779                // Redacted.
780                Authorization::Unknown
781            }
782            // Anyone can react to a revision.
783            Action::RevisionCommentReact { .. } => Authorization::Allow,
784        };
785        Ok(outcome)
786    }
787}
788
789impl Patch {
790    /// Apply an action after checking if it's authorized.
791    fn op_action<R: ReadRepository>(
792        &mut self,
793        action: Action,
794        id: EntryId,
795        author: ActorId,
796        timestamp: Timestamp,
797        concurrent: &[&cob::Entry],
798        doc: &DocAt,
799        repo: &R,
800    ) -> Result<(), Error> {
801        match self.authorization(&action, &author, doc)? {
802            Authorization::Allow => {
803                self.action(action, id, author, timestamp, concurrent, doc, repo)
804            }
805            Authorization::Deny => Err(Error::NotAuthorized(author, Box::new(action))),
806            Authorization::Unknown => {
807                // In this case, since there is not enough information to determine
808                // whether the action is authorized or not, we simply ignore it.
809                // It's likely that the target object was redacted, and we can't
810                // verify whether the action would have been allowed or not.
811                Ok(())
812            }
813        }
814    }
815
816    /// Apply a single action to the patch.
817    fn action<R: ReadRepository>(
818        &mut self,
819        action: Action,
820        entry: EntryId,
821        author: ActorId,
822        timestamp: Timestamp,
823        _concurrent: &[&cob::Entry],
824        identity: &Doc,
825        repo: &R,
826    ) -> Result<(), Error> {
827        match action {
828            Action::Edit { title, target } => {
829                self.title = title;
830                self.target = target;
831            }
832            Action::Lifecycle { state } => {
833                let valid = self.state == State::Draft
834                    || self.state == State::Archived
835                    || self.state == State::Open { conflicts: vec![] };
836
837                if valid {
838                    match state {
839                        Lifecycle::Open => {
840                            self.state = State::Open { conflicts: vec![] };
841                        }
842                        Lifecycle::Draft => {
843                            self.state = State::Draft;
844                        }
845                        Lifecycle::Archived => {
846                            self.state = State::Archived;
847                        }
848                    }
849                }
850            }
851            Action::Label { labels } => {
852                self.labels = BTreeSet::from_iter(labels);
853            }
854            Action::Assign { assignees } => {
855                self.assignees = BTreeSet::from_iter(assignees.into_iter().map(ActorId::from));
856            }
857            Action::RevisionEdit {
858                revision,
859                description,
860                embeds,
861            } => {
862                if let Some(redactable) = self.revisions.get_mut(&revision) {
863                    // If the revision was redacted concurrently, there's nothing to do.
864                    if let Some(revision) = redactable {
865                        revision.description.push(Edit::new(
866                            author,
867                            description,
868                            timestamp,
869                            embeds,
870                        ));
871                    }
872                } else {
873                    return Err(Error::Missing(revision.into_inner()));
874                }
875            }
876            Action::Revision {
877                description,
878                base,
879                oid,
880                resolves,
881            } => {
882                debug_assert!(!self.revisions.contains_key(&entry));
883                let id = RevisionId(entry);
884
885                self.revisions.insert(
886                    id,
887                    Some(Revision::new(
888                        id,
889                        author.into(),
890                        description,
891                        base,
892                        oid,
893                        timestamp,
894                        resolves,
895                    )),
896                );
897            }
898            Action::RevisionReact {
899                revision,
900                reaction,
901                active,
902                location,
903            } => {
904                if let Some(revision) = lookup::revision_mut(self, &revision)? {
905                    let key = (author, reaction);
906                    let reactions = revision.reactions.entry(location).or_default();
907
908                    if active {
909                        reactions.insert(key);
910                    } else {
911                        reactions.remove(&key);
912                    }
913                }
914            }
915            Action::RevisionRedact { revision } => {
916                // Not allowed to delete the root revision.
917                let (root, _) = self.root();
918                if revision == root {
919                    return Err(Error::NotAllowed(entry));
920                }
921                // Redactions must have observed a revision to be valid.
922                if let Some(r) = self.revisions.get_mut(&revision) {
923                    // If the revision has already been merged, ignore the redaction. We
924                    // don't want to redact merged revisions.
925                    if self.merges.values().any(|m| m.revision == revision) {
926                        return Ok(());
927                    }
928                    *r = None;
929                } else {
930                    return Err(Error::Missing(revision.into_inner()));
931                }
932            }
933            Action::Review {
934                revision,
935                summary,
936                verdict,
937                labels,
938            } => {
939                let Some(rev) = self.revisions.get_mut(&revision) else {
940                    // If the revision was redacted concurrently, there's nothing to do.
941                    return Ok(());
942                };
943                if let Some(rev) = rev {
944                    // Insert a review if there isn't already one. Otherwise we just ignore
945                    // this operation
946                    if let btree_map::Entry::Vacant(e) = rev.reviews.entry(author) {
947                        let id = ReviewId(entry);
948
949                        e.insert(Review::new(
950                            id,
951                            Author::new(author),
952                            verdict,
953                            summary.unwrap_or_default(),
954                            labels,
955                            vec![],
956                            timestamp,
957                        ));
958                        // Update reviews index.
959                        self.reviews.insert(id, Some((revision, author)));
960                    } else {
961                        log::error!(
962                            target: "patch",
963                            "Review by {author} for {revision} already exists, ignoring action.."
964                        );
965                    }
966                }
967            }
968            Action::ReviewEdit(edit) => edit.run(author, timestamp, self)?,
969            Action::ReviewCommentReact {
970                review,
971                comment,
972                reaction,
973                active,
974            } => {
975                if let Some(review) = lookup::review_mut(self, &review)? {
976                    thread::react(
977                        &mut review.comments,
978                        entry,
979                        author,
980                        comment,
981                        reaction,
982                        active,
983                    )?;
984                }
985            }
986            Action::ReviewCommentRedact { review, comment } => {
987                if let Some(review) = lookup::review_mut(self, &review)? {
988                    thread::redact(&mut review.comments, entry, comment)?;
989                }
990            }
991            Action::ReviewCommentEdit {
992                review,
993                comment,
994                body,
995                embeds,
996            } => {
997                if let Some(review) = lookup::review_mut(self, &review)? {
998                    thread::edit(
999                        &mut review.comments,
1000                        entry,
1001                        author,
1002                        comment,
1003                        timestamp,
1004                        body,
1005                        embeds,
1006                    )?;
1007                }
1008            }
1009            Action::ReviewCommentResolve { review, comment } => {
1010                if let Some(review) = lookup::review_mut(self, &review)? {
1011                    thread::resolve(&mut review.comments, entry, comment)?;
1012                }
1013            }
1014            Action::ReviewCommentUnresolve { review, comment } => {
1015                if let Some(review) = lookup::review_mut(self, &review)? {
1016                    thread::unresolve(&mut review.comments, entry, comment)?;
1017                }
1018            }
1019            Action::ReviewComment {
1020                review,
1021                body,
1022                location,
1023                reply_to,
1024                embeds,
1025            } => {
1026                if let Some(review) = lookup::review_mut(self, &review)? {
1027                    thread::comment(
1028                        &mut review.comments,
1029                        entry,
1030                        author,
1031                        timestamp,
1032                        body,
1033                        reply_to,
1034                        location,
1035                        embeds,
1036                    )?;
1037                }
1038            }
1039            Action::ReviewRedact { review } => {
1040                // Redactions must have observed a review to be valid.
1041                let Some(locator) = self.reviews.get_mut(&review) else {
1042                    return Err(Error::Missing(review.into_inner()));
1043                };
1044                // If the review is already redacted, do nothing.
1045                let Some((revision, reviewer)) = locator else {
1046                    return Ok(());
1047                };
1048                // The revision must have existed at some point.
1049                let Some(redactable) = self.revisions.get_mut(revision) else {
1050                    return Err(Error::Missing(revision.into_inner()));
1051                };
1052                // But it could be redacted.
1053                let Some(revision) = redactable else {
1054                    return Ok(());
1055                };
1056                // Remove review for this author.
1057                if let Some(r) = revision.reviews.remove(reviewer) {
1058                    debug_assert_eq!(r.id, review);
1059                } else {
1060                    log::error!(
1061                        target: "patch", "Review {review} not found in revision {}", revision.id
1062                    );
1063                }
1064                // Set the review locator in the review index to redacted.
1065                *locator = None;
1066            }
1067            Action::ReviewReact {
1068                review,
1069                reaction,
1070                active,
1071            } => {
1072                if let Some(review) = lookup::review_mut(self, &review)? {
1073                    if active {
1074                        review.reactions.insert((author, reaction));
1075                    } else {
1076                        review.reactions.remove(&(author, reaction));
1077                    }
1078                }
1079            }
1080            Action::Merge { revision, commit } => {
1081                // If the revision was redacted before the merge, ignore the merge.
1082                if lookup::revision_mut(self, &revision)?.is_none() {
1083                    return Ok(());
1084                };
1085                match self.target() {
1086                    MergeTarget::Delegates => {
1087                        let proj = identity.project()?;
1088                        let branch = git::refs::branch(proj.default_branch());
1089
1090                        // Nb. We don't return an error in case the merge commit is not an
1091                        // ancestor of the default branch. The default branch can change
1092                        // *after* the merge action is created, which is out of the control
1093                        // of the merge author. We simply skip it, which allows archiving in
1094                        // case of a rebase off the master branch, or a redaction of the
1095                        // merge.
1096                        let Ok(head) = repo.reference_oid(&author, &branch) else {
1097                            return Ok(());
1098                        };
1099                        if commit != head && !repo.is_ancestor_of(commit, head)? {
1100                            return Ok(());
1101                        }
1102                    }
1103                }
1104                self.merges.insert(
1105                    author,
1106                    Merge {
1107                        revision,
1108                        commit,
1109                        timestamp,
1110                    },
1111                );
1112
1113                let mut merges = self.merges.iter().fold(
1114                    HashMap::<(RevisionId, git::Oid), usize>::new(),
1115                    |mut acc, (_, merge)| {
1116                        *acc.entry((merge.revision, merge.commit)).or_default() += 1;
1117                        acc
1118                    },
1119                );
1120                // Discard revisions that weren't merged by a threshold of delegates.
1121                merges.retain(|_, count| *count >= identity.threshold());
1122
1123                match merges.into_keys().collect::<Vec<_>>().as_slice() {
1124                    [] => {
1125                        // None of the revisions met the quorum.
1126                    }
1127                    [(revision, commit)] => {
1128                        // Patch is merged.
1129                        self.state = State::Merged {
1130                            revision: *revision,
1131                            commit: *commit,
1132                        };
1133                    }
1134                    revisions => {
1135                        // More than one revision met the quorum.
1136                        self.state = State::Open {
1137                            conflicts: revisions.to_vec(),
1138                        };
1139                    }
1140                }
1141            }
1142
1143            Action::RevisionComment {
1144                revision,
1145                body,
1146                reply_to,
1147                embeds,
1148                location,
1149            } => {
1150                if let Some(revision) = lookup::revision_mut(self, &revision)? {
1151                    thread::comment(
1152                        &mut revision.discussion,
1153                        entry,
1154                        author,
1155                        timestamp,
1156                        body,
1157                        reply_to,
1158                        location,
1159                        embeds,
1160                    )?;
1161                }
1162            }
1163            Action::RevisionCommentEdit {
1164                revision,
1165                comment,
1166                body,
1167                embeds,
1168            } => {
1169                if let Some(revision) = lookup::revision_mut(self, &revision)? {
1170                    thread::edit(
1171                        &mut revision.discussion,
1172                        entry,
1173                        author,
1174                        comment,
1175                        timestamp,
1176                        body,
1177                        embeds,
1178                    )?;
1179                }
1180            }
1181            Action::RevisionCommentRedact { revision, comment } => {
1182                if let Some(revision) = lookup::revision_mut(self, &revision)? {
1183                    thread::redact(&mut revision.discussion, entry, comment)?;
1184                }
1185            }
1186            Action::RevisionCommentReact {
1187                revision,
1188                comment,
1189                reaction,
1190                active,
1191            } => {
1192                if let Some(revision) = lookup::revision_mut(self, &revision)? {
1193                    thread::react(
1194                        &mut revision.discussion,
1195                        entry,
1196                        author,
1197                        comment,
1198                        reaction,
1199                        active,
1200                    )?;
1201                }
1202            }
1203        }
1204        Ok(())
1205    }
1206}
1207
1208impl cob::store::CobWithType for Patch {
1209    fn type_name() -> &'static TypeName {
1210        &TYPENAME
1211    }
1212}
1213
1214impl store::Cob for Patch {
1215    type Action = Action;
1216    type Error = Error;
1217
1218    fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
1219        let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
1220        let mut actions = op.actions.into_iter();
1221        let Some(Action::Revision {
1222            description,
1223            base,
1224            oid,
1225            resolves,
1226        }) = actions.next()
1227        else {
1228            return Err(Error::Init("the first action must be of type `revision`"));
1229        };
1230        let Some(Action::Edit { title, target }) = actions.next() else {
1231            return Err(Error::Init("the second action must be of type `edit`"));
1232        };
1233        let revision = Revision::new(
1234            RevisionId(op.id),
1235            op.author.into(),
1236            description,
1237            base,
1238            oid,
1239            op.timestamp,
1240            resolves,
1241        );
1242        let mut patch = Patch::new(title, target, (RevisionId(op.id), revision));
1243
1244        for action in actions {
1245            match patch.authorization(&action, &op.author, &doc)? {
1246                Authorization::Allow => {
1247                    patch.action(action, op.id, op.author, op.timestamp, &[], &doc, repo)?;
1248                }
1249                Authorization::Deny => {
1250                    return Err(Error::NotAuthorized(op.author, Box::new(action)));
1251                }
1252                Authorization::Unknown => {
1253                    // Note that this shouldn't really happen since there's no concurrency in the
1254                    // root operation.
1255                    continue;
1256                }
1257            }
1258        }
1259        Ok(patch)
1260    }
1261
1262    fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a cob::Entry>>(
1263        &mut self,
1264        op: Op,
1265        concurrent: I,
1266        repo: &R,
1267    ) -> Result<(), Error> {
1268        debug_assert!(!self.timeline.contains(&op.id));
1269        self.timeline.push(op.id);
1270
1271        let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
1272        let concurrent = concurrent.into_iter().collect::<Vec<_>>();
1273
1274        for action in op.actions {
1275            log::trace!(target: "patch", "Applying {} {action:?}", op.id);
1276
1277            if let Err(e) = self.op_action(
1278                action,
1279                op.id,
1280                op.author,
1281                op.timestamp,
1282                &concurrent,
1283                &doc,
1284                repo,
1285            ) {
1286                log::error!(target: "patch", "Error applying {}: {e}", op.id);
1287                return Err(e);
1288            }
1289        }
1290        Ok(())
1291    }
1292}
1293
1294impl<R: ReadRepository> cob::Evaluate<R> for Patch {
1295    type Error = Error;
1296
1297    fn init(entry: &cob::Entry, repo: &R) -> Result<Self, Self::Error> {
1298        let op = Op::try_from(entry)?;
1299        let object = Patch::from_root(op, repo)?;
1300
1301        Ok(object)
1302    }
1303
1304    fn apply<'a, I: Iterator<Item = (&'a EntryId, &'a cob::Entry)>>(
1305        &mut self,
1306        entry: &cob::Entry,
1307        concurrent: I,
1308        repo: &R,
1309    ) -> Result<(), Self::Error> {
1310        let op = Op::try_from(entry)?;
1311
1312        self.op(op, concurrent.map(|(_, e)| e), repo)
1313    }
1314}
1315
1316mod lookup {
1317    use super::*;
1318
1319    pub fn revision<'a>(
1320        patch: &'a Patch,
1321        revision: &RevisionId,
1322    ) -> Result<Option<&'a Revision>, Error> {
1323        match patch.revisions.get(revision) {
1324            Some(Some(revision)) => Ok(Some(revision)),
1325            // Redacted.
1326            Some(None) => Ok(None),
1327            // Missing. Causal error.
1328            None => Err(Error::Missing(revision.into_inner())),
1329        }
1330    }
1331
1332    pub fn revision_mut<'a>(
1333        patch: &'a mut Patch,
1334        revision: &RevisionId,
1335    ) -> Result<Option<&'a mut Revision>, Error> {
1336        match patch.revisions.get_mut(revision) {
1337            Some(Some(revision)) => Ok(Some(revision)),
1338            // Redacted.
1339            Some(None) => Ok(None),
1340            // Missing. Causal error.
1341            None => Err(Error::Missing(revision.into_inner())),
1342        }
1343    }
1344
1345    pub fn review<'a>(
1346        patch: &'a Patch,
1347        review: &ReviewId,
1348    ) -> Result<Option<(&'a Revision, &'a Review)>, Error> {
1349        match patch.reviews.get(review) {
1350            Some(Some((revision, author))) => {
1351                match patch.revisions.get(revision) {
1352                    Some(Some(rev)) => {
1353                        let r = rev
1354                            .reviews
1355                            .get(author)
1356                            .ok_or_else(|| Error::Missing(review.into_inner()))?;
1357                        debug_assert_eq!(&r.id, review);
1358
1359                        Ok(Some((rev, r)))
1360                    }
1361                    Some(None) => {
1362                        // If the revision was redacted concurrently, there's nothing to do.
1363                        // Likewise, if the review was redacted concurrently, there's nothing to do.
1364                        Ok(None)
1365                    }
1366                    None => Err(Error::Missing(revision.into_inner())),
1367                }
1368            }
1369            Some(None) => {
1370                // Redacted.
1371                Ok(None)
1372            }
1373            None => Err(Error::Missing(review.into_inner())),
1374        }
1375    }
1376
1377    pub fn review_mut<'a>(
1378        patch: &'a mut Patch,
1379        review: &ReviewId,
1380    ) -> Result<Option<&'a mut Review>, Error> {
1381        match patch.reviews.get(review) {
1382            Some(Some((revision, author))) => {
1383                match patch.revisions.get_mut(revision) {
1384                    Some(Some(rev)) => {
1385                        let r = rev
1386                            .reviews
1387                            .get_mut(author)
1388                            .ok_or_else(|| Error::Missing(review.into_inner()))?;
1389                        debug_assert_eq!(&r.id, review);
1390
1391                        Ok(Some(r))
1392                    }
1393                    Some(None) => {
1394                        // If the revision was redacted concurrently, there's nothing to do.
1395                        // Likewise, if the review was redacted concurrently, there's nothing to do.
1396                        Ok(None)
1397                    }
1398                    None => Err(Error::Missing(revision.into_inner())),
1399                }
1400            }
1401            Some(None) => {
1402                // Redacted.
1403                Ok(None)
1404            }
1405            None => Err(Error::Missing(review.into_inner())),
1406        }
1407    }
1408}
1409
1410/// A patch revision.
1411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1412#[serde(rename_all = "camelCase")]
1413pub struct Revision {
1414    /// Revision identifier.
1415    pub(super) id: RevisionId,
1416    /// Author of the revision.
1417    pub(super) author: Author,
1418    /// Revision description.
1419    pub(super) description: NonEmpty<Edit>,
1420    /// Base branch commit, used as a merge base.
1421    pub(super) base: git::Oid,
1422    /// Reference to the Git object containing the code (revision head).
1423    pub(super) oid: git::Oid,
1424    /// Discussion around this revision.
1425    pub(super) discussion: Thread<Comment<CodeLocation>>,
1426    /// Reviews of this revision's changes (all review edits are kept).
1427    pub(super) reviews: BTreeMap<ActorId, Review>,
1428    /// When this revision was created.
1429    pub(super) timestamp: Timestamp,
1430    /// Review comments resolved by this revision.
1431    pub(super) resolves: BTreeSet<(EntryId, CommentId)>,
1432    /// Reactions on code locations and revision itself
1433    #[serde(
1434        serialize_with = "ser::serialize_reactions",
1435        deserialize_with = "ser::deserialize_reactions"
1436    )]
1437    pub(super) reactions: BTreeMap<Option<CodeLocation>, Reactions>,
1438}
1439
1440impl Revision {
1441    pub fn new(
1442        id: RevisionId,
1443        author: Author,
1444        description: String,
1445        base: git::Oid,
1446        oid: git::Oid,
1447        timestamp: Timestamp,
1448        resolves: BTreeSet<(EntryId, CommentId)>,
1449    ) -> Self {
1450        let description = Edit::new(*author.public_key(), description, timestamp, Vec::default());
1451
1452        Self {
1453            id,
1454            author,
1455            description: NonEmpty::new(description),
1456            base,
1457            oid,
1458            discussion: Thread::default(),
1459            reviews: BTreeMap::default(),
1460            timestamp,
1461            resolves,
1462            reactions: Default::default(),
1463        }
1464    }
1465
1466    pub fn id(&self) -> RevisionId {
1467        self.id
1468    }
1469
1470    pub fn description(&self) -> &str {
1471        self.description.last().body.as_str()
1472    }
1473
1474    pub fn edits(&self) -> impl Iterator<Item = &Edit> {
1475        self.description.iter()
1476    }
1477
1478    pub fn embeds(&self) -> &[Embed<Uri>] {
1479        &self.description.last().embeds
1480    }
1481
1482    pub fn reactions(&self) -> &BTreeMap<Option<CodeLocation>, BTreeSet<(PublicKey, Reaction)>> {
1483        &self.reactions
1484    }
1485
1486    /// Author of the revision.
1487    pub fn author(&self) -> &Author {
1488        &self.author
1489    }
1490
1491    /// Base branch commit, used as a merge base.
1492    pub fn base(&self) -> &git::Oid {
1493        &self.base
1494    }
1495
1496    /// Reference to the Git object containing the code (revision head).
1497    pub fn head(&self) -> git::Oid {
1498        self.oid
1499    }
1500
1501    /// Get the commit range of this revision.
1502    pub fn range(&self) -> (git::Oid, git::Oid) {
1503        (self.base, self.oid)
1504    }
1505
1506    /// When this revision was created.
1507    pub fn timestamp(&self) -> Timestamp {
1508        self.timestamp
1509    }
1510
1511    /// Discussion around this revision.
1512    pub fn discussion(&self) -> &Thread<Comment<CodeLocation>> {
1513        &self.discussion
1514    }
1515
1516    /// Review comments resolved by this revision.
1517    pub fn resolves(&self) -> &BTreeSet<(EntryId, CommentId)> {
1518        &self.resolves
1519    }
1520
1521    /// Iterate over all top-level replies.
1522    pub fn replies(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment<CodeLocation>)> {
1523        self.discussion.comments()
1524    }
1525
1526    /// Reviews of this revision's changes (one per actor).
1527    pub fn reviews(&self) -> impl DoubleEndedIterator<Item = (&PublicKey, &Review)> {
1528        self.reviews.iter()
1529    }
1530
1531    /// Get a review by author.
1532    pub fn review_by(&self, author: &ActorId) -> Option<&Review> {
1533        self.reviews.get(author)
1534    }
1535}
1536
1537/// Patch state.
1538#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1539#[serde(rename_all = "camelCase", tag = "status")]
1540pub enum State {
1541    Draft,
1542    Open {
1543        /// Revisions that were merged and are conflicting.
1544        #[serde(skip_serializing_if = "Vec::is_empty")]
1545        #[serde(default)]
1546        conflicts: Vec<(RevisionId, git::Oid)>,
1547    },
1548    Archived,
1549    Merged {
1550        /// The revision that was merged.
1551        revision: RevisionId,
1552        /// The commit in the target branch that contains the changes.
1553        commit: git::Oid,
1554    },
1555}
1556
1557impl Default for State {
1558    fn default() -> Self {
1559        Self::Open { conflicts: vec![] }
1560    }
1561}
1562
1563impl fmt::Display for State {
1564    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1565        match self {
1566            Self::Archived => write!(f, "archived"),
1567            Self::Draft => write!(f, "draft"),
1568            Self::Open { .. } => write!(f, "open"),
1569            Self::Merged { .. } => write!(f, "merged"),
1570        }
1571    }
1572}
1573
1574impl From<&State> for Status {
1575    fn from(value: &State) -> Self {
1576        match value {
1577            State::Draft => Self::Draft,
1578            State::Open { .. } => Self::Open,
1579            State::Archived => Self::Archived,
1580            State::Merged { .. } => Self::Merged,
1581        }
1582    }
1583}
1584
1585/// A simplified enumeration of a [`State`] that can be used for
1586/// filtering purposes.
1587#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1588pub enum Status {
1589    Draft,
1590    #[default]
1591    Open,
1592    Archived,
1593    Merged,
1594}
1595
1596impl fmt::Display for Status {
1597    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1598        match self {
1599            Self::Archived => write!(f, "archived"),
1600            Self::Draft => write!(f, "draft"),
1601            Self::Open => write!(f, "open"),
1602            Self::Merged => write!(f, "merged"),
1603        }
1604    }
1605}
1606
1607/// A lifecycle operation, resulting in a new state.
1608#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1609#[serde(rename_all = "camelCase", tag = "status")]
1610pub enum Lifecycle {
1611    #[default]
1612    Open,
1613    Draft,
1614    Archived,
1615}
1616
1617/// A merged patch revision.
1618#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
1619#[serde(rename_all = "camelCase")]
1620pub struct Merge {
1621    /// Revision that was merged.
1622    pub revision: RevisionId,
1623    /// Base branch commit that contains the revision.
1624    pub commit: git::Oid,
1625    /// When this merge was performed.
1626    pub timestamp: Timestamp,
1627}
1628
1629/// A patch review verdict.
1630#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1631#[serde(rename_all = "camelCase")]
1632pub enum Verdict {
1633    /// Accept patch.
1634    Accept,
1635    /// Reject patch.
1636    Reject,
1637}
1638
1639impl fmt::Display for Verdict {
1640    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1641        match self {
1642            Self::Accept => write!(f, "accept"),
1643            Self::Reject => write!(f, "reject"),
1644        }
1645    }
1646}
1647
1648/// A patch review on a revision.
1649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1650#[serde(rename_all = "camelCase")]
1651#[serde(from = "encoding::review::Review")]
1652pub struct Review {
1653    /// Review identifier.
1654    pub(super) id: ReviewId,
1655    /// Review author.
1656    pub(super) author: Author,
1657    /// Review verdict.
1658    ///
1659    /// The verdict cannot be changed, since revisions are immutable.
1660    pub(super) verdict: Option<Verdict>,
1661    /// Review summary.
1662    ///
1663    /// Can be empty, given there is a [`Verdict`].
1664    ///
1665    /// If not empty, then the last [`Edit`] in the `Vec` will be the latest
1666    /// edit of the summary.
1667    pub(super) summary: NonEmpty<Edit>,
1668    /// Review comments.
1669    pub(super) comments: Thread<Comment<CodeLocation>>,
1670    /// Labels qualifying the review. For example if this review only looks at the
1671    /// concept or intention of the patch, it could have a "concept" label.
1672    pub(super) labels: Vec<Label>,
1673    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
1674    /// Reactions to the review.
1675    pub(super) reactions: Reactions,
1676    /// Review timestamp.
1677    pub(super) timestamp: Timestamp,
1678}
1679
1680impl Review {
1681    pub fn new(
1682        id: ReviewId,
1683        author: Author,
1684        verdict: Option<Verdict>,
1685        summary: String,
1686        labels: Vec<Label>,
1687        embeds: Vec<Embed<Uri>>,
1688        timestamp: Timestamp,
1689    ) -> Self {
1690        let summary = NonEmpty::new(Edit::new(*author.public_key(), summary, timestamp, embeds));
1691        Self {
1692            id,
1693            author,
1694            verdict,
1695            summary,
1696            comments: Thread::default(),
1697            reactions: BTreeSet::new(),
1698            labels,
1699            timestamp,
1700        }
1701    }
1702
1703    /// Review identifier.
1704    pub fn id(&self) -> ReviewId {
1705        self.id
1706    }
1707
1708    /// Review author.
1709    pub fn author(&self) -> &Author {
1710        &self.author
1711    }
1712
1713    /// Review verdict.
1714    pub fn verdict(&self) -> Option<Verdict> {
1715        self.verdict
1716    }
1717
1718    /// Review inline code comments.
1719    pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&EntryId, &Comment<CodeLocation>)> {
1720        self.comments.comments()
1721    }
1722
1723    /// Review labels.
1724    pub fn labels(&self) -> impl Iterator<Item = &Label> {
1725        self.labels.iter()
1726    }
1727
1728    /// Review general comment.
1729    pub fn summary(&self) -> &str {
1730        self.summary.last().body.as_str()
1731    }
1732
1733    /// Review embeds.
1734    pub fn embeds(&self) -> &[Embed<Uri>] {
1735        &self.summary.last().embeds
1736    }
1737
1738    /// Review reactions.
1739    pub fn reactions(&self) -> &Reactions {
1740        &self.reactions
1741    }
1742
1743    /// Get the review summary edits.
1744    pub fn edits(&self) -> impl Iterator<Item = &Edit> {
1745        self.summary.iter()
1746    }
1747
1748    /// Review timestamp.
1749    pub fn timestamp(&self) -> Timestamp {
1750        self.timestamp
1751    }
1752}
1753
1754impl<R: ReadRepository> store::Transaction<Patch, R> {
1755    pub fn edit(&mut self, title: cob::Title, target: MergeTarget) -> Result<(), store::Error> {
1756        self.push(Action::Edit { title, target })
1757    }
1758
1759    pub fn edit_revision(
1760        &mut self,
1761        revision: RevisionId,
1762        description: impl ToString,
1763        embeds: Vec<Embed<Uri>>,
1764    ) -> Result<(), store::Error> {
1765        self.embed(embeds.clone())?;
1766        self.push(Action::RevisionEdit {
1767            revision,
1768            description: description.to_string(),
1769            embeds,
1770        })
1771    }
1772
1773    /// Redact the revision.
1774    pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> {
1775        self.push(Action::RevisionRedact { revision })
1776    }
1777
1778    /// Start a patch revision discussion.
1779    pub fn thread<S: ToString>(
1780        &mut self,
1781        revision: RevisionId,
1782        body: S,
1783    ) -> Result<(), store::Error> {
1784        self.push(Action::RevisionComment {
1785            revision,
1786            body: body.to_string(),
1787            reply_to: None,
1788            location: None,
1789            embeds: vec![],
1790        })
1791    }
1792
1793    /// React on a patch revision.
1794    pub fn react(
1795        &mut self,
1796        revision: RevisionId,
1797        reaction: Reaction,
1798        location: Option<CodeLocation>,
1799        active: bool,
1800    ) -> Result<(), store::Error> {
1801        self.push(Action::RevisionReact {
1802            revision,
1803            reaction,
1804            location,
1805            active,
1806        })
1807    }
1808
1809    /// Comment on a patch revision.
1810    pub fn comment<S: ToString>(
1811        &mut self,
1812        revision: RevisionId,
1813        body: S,
1814        reply_to: Option<CommentId>,
1815        location: Option<CodeLocation>,
1816        embeds: Vec<Embed<Uri>>,
1817    ) -> Result<(), store::Error> {
1818        self.embed(embeds.clone())?;
1819        self.push(Action::RevisionComment {
1820            revision,
1821            body: body.to_string(),
1822            reply_to,
1823            location,
1824            embeds,
1825        })
1826    }
1827
1828    /// Edit a comment on a patch revision.
1829    pub fn comment_edit<S: ToString>(
1830        &mut self,
1831        revision: RevisionId,
1832        comment: CommentId,
1833        body: S,
1834        embeds: Vec<Embed<Uri>>,
1835    ) -> Result<(), store::Error> {
1836        self.embed(embeds.clone())?;
1837        self.push(Action::RevisionCommentEdit {
1838            revision,
1839            comment,
1840            body: body.to_string(),
1841            embeds,
1842        })
1843    }
1844
1845    /// React a comment on a patch revision.
1846    pub fn comment_react(
1847        &mut self,
1848        revision: RevisionId,
1849        comment: CommentId,
1850        reaction: Reaction,
1851        active: bool,
1852    ) -> Result<(), store::Error> {
1853        self.push(Action::RevisionCommentReact {
1854            revision,
1855            comment,
1856            reaction,
1857            active,
1858        })
1859    }
1860
1861    /// Redact a comment on a patch revision.
1862    pub fn comment_redact(
1863        &mut self,
1864        revision: RevisionId,
1865        comment: CommentId,
1866    ) -> Result<(), store::Error> {
1867        self.push(Action::RevisionCommentRedact { revision, comment })
1868    }
1869
1870    /// Comment on a review.
1871    pub fn review_comment<S: ToString>(
1872        &mut self,
1873        review: ReviewId,
1874        body: S,
1875        location: Option<CodeLocation>,
1876        reply_to: Option<CommentId>,
1877        embeds: Vec<Embed<Uri>>,
1878    ) -> Result<(), store::Error> {
1879        self.embed(embeds.clone())?;
1880        self.push(Action::ReviewComment {
1881            review,
1882            body: body.to_string(),
1883            location,
1884            reply_to,
1885            embeds,
1886        })
1887    }
1888
1889    /// Resolve a review comment.
1890    pub fn review_comment_resolve(
1891        &mut self,
1892        review: ReviewId,
1893        comment: CommentId,
1894    ) -> Result<(), store::Error> {
1895        self.push(Action::ReviewCommentResolve { review, comment })
1896    }
1897
1898    /// Unresolve a review comment.
1899    pub fn review_comment_unresolve(
1900        &mut self,
1901        review: ReviewId,
1902        comment: CommentId,
1903    ) -> Result<(), store::Error> {
1904        self.push(Action::ReviewCommentUnresolve { review, comment })
1905    }
1906
1907    /// Edit review comment.
1908    pub fn edit_review_comment<S: ToString>(
1909        &mut self,
1910        review: ReviewId,
1911        comment: EntryId,
1912        body: S,
1913        embeds: Vec<Embed<Uri>>,
1914    ) -> Result<(), store::Error> {
1915        self.embed(embeds.clone())?;
1916        self.push(Action::ReviewCommentEdit {
1917            review,
1918            comment,
1919            body: body.to_string(),
1920            embeds,
1921        })
1922    }
1923
1924    /// React to a review comment.
1925    pub fn react_review_comment(
1926        &mut self,
1927        review: ReviewId,
1928        comment: EntryId,
1929        reaction: Reaction,
1930        active: bool,
1931    ) -> Result<(), store::Error> {
1932        self.push(Action::ReviewCommentReact {
1933            review,
1934            comment,
1935            reaction,
1936            active,
1937        })
1938    }
1939
1940    /// Redact a review comment.
1941    pub fn redact_review_comment(
1942        &mut self,
1943        review: ReviewId,
1944        comment: EntryId,
1945    ) -> Result<(), store::Error> {
1946        self.push(Action::ReviewCommentRedact { review, comment })
1947    }
1948
1949    /// Review a patch revision.
1950    /// Does nothing if a review for that revision already exists by the author.
1951    pub fn review(
1952        &mut self,
1953        revision: RevisionId,
1954        verdict: Option<Verdict>,
1955        summary: Option<String>,
1956        labels: Vec<Label>,
1957    ) -> Result<(), store::Error> {
1958        self.push(Action::Review {
1959            revision,
1960            summary,
1961            verdict,
1962            labels,
1963        })
1964    }
1965
1966    /// Edit a review.
1967    pub fn review_edit(
1968        &mut self,
1969        review: ReviewId,
1970        verdict: Option<Verdict>,
1971        summary: String,
1972        labels: Vec<Label>,
1973        embeds: impl IntoIterator<Item = Embed<Uri>>,
1974    ) -> Result<(), store::Error> {
1975        self.push(Action::ReviewEdit(actions::ReviewEdit::new(
1976            review,
1977            summary,
1978            verdict,
1979            labels,
1980            embeds.into_iter().collect(),
1981        )))
1982    }
1983
1984    /// React to a review.
1985    pub fn review_react(
1986        &mut self,
1987        review: ReviewId,
1988        reaction: Reaction,
1989        active: bool,
1990    ) -> Result<(), store::Error> {
1991        self.push(Action::ReviewReact {
1992            review,
1993            reaction,
1994            active,
1995        })
1996    }
1997
1998    /// Redact a patch review.
1999    pub fn redact_review(&mut self, review: ReviewId) -> Result<(), store::Error> {
2000        self.push(Action::ReviewRedact { review })
2001    }
2002
2003    /// Merge a patch revision.
2004    pub fn merge(&mut self, revision: RevisionId, commit: git::Oid) -> Result<(), store::Error> {
2005        self.push(Action::Merge { revision, commit })
2006    }
2007
2008    /// Update a patch with a new revision.
2009    pub fn revision(
2010        &mut self,
2011        description: impl ToString,
2012        base: impl Into<git::Oid>,
2013        oid: impl Into<git::Oid>,
2014    ) -> Result<(), store::Error> {
2015        self.push(Action::Revision {
2016            description: description.to_string(),
2017            base: base.into(),
2018            oid: oid.into(),
2019            resolves: BTreeSet::new(),
2020        })
2021    }
2022
2023    /// Lifecycle a patch.
2024    pub fn lifecycle(&mut self, state: Lifecycle) -> Result<(), store::Error> {
2025        self.push(Action::Lifecycle { state })
2026    }
2027
2028    /// Assign a patch.
2029    pub fn assign(&mut self, assignees: BTreeSet<Did>) -> Result<(), store::Error> {
2030        self.push(Action::Assign { assignees })
2031    }
2032
2033    /// Label a patch.
2034    pub fn label(&mut self, labels: impl IntoIterator<Item = Label>) -> Result<(), store::Error> {
2035        self.push(Action::Label {
2036            labels: labels.into_iter().collect(),
2037        })
2038    }
2039}
2040
2041pub struct PatchMut<'a, 'g, R, C> {
2042    pub id: ObjectId,
2043
2044    patch: Patch,
2045    store: &'g mut Patches<'a, R>,
2046    cache: &'g mut C,
2047}
2048
2049impl<'a, 'g, R, C> PatchMut<'a, 'g, R, C>
2050where
2051    C: cob::cache::Update<Patch>,
2052    R: ReadRepository + SignRepository + cob::Store<Namespace = NodeId>,
2053{
2054    pub fn new(id: ObjectId, patch: Patch, cache: &'g mut Cache<Patches<'a, R>, C>) -> Self {
2055        Self {
2056            id,
2057            patch,
2058            store: &mut cache.store,
2059            cache: &mut cache.cache,
2060        }
2061    }
2062
2063    pub fn id(&self) -> &ObjectId {
2064        &self.id
2065    }
2066
2067    /// Reload the patch data from storage.
2068    pub fn reload(&mut self) -> Result<(), store::Error> {
2069        self.patch = self
2070            .store
2071            .get(&self.id)?
2072            .ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?;
2073
2074        Ok(())
2075    }
2076
2077    pub fn transaction<G, F>(
2078        &mut self,
2079        message: &str,
2080        signer: &Device<G>,
2081        operations: F,
2082    ) -> Result<EntryId, Error>
2083    where
2084        G: crypto::signature::Signer<crypto::Signature>,
2085        F: FnOnce(&mut Transaction<Patch, R>) -> Result<(), store::Error>,
2086    {
2087        let mut tx = Transaction::default();
2088        operations(&mut tx)?;
2089
2090        let (patch, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
2091        self.cache
2092            .update(&self.store.as_ref().id(), &self.id, &patch)
2093            .map_err(|e| Error::CacheUpdate {
2094                id: self.id,
2095                err: e.into(),
2096            })?;
2097        self.patch = patch;
2098
2099        Ok(commit)
2100    }
2101
2102    /// Edit patch metadata.
2103    pub fn edit<G, S>(
2104        &mut self,
2105        title: cob::Title,
2106        target: MergeTarget,
2107        signer: &Device<G>,
2108    ) -> Result<EntryId, Error>
2109    where
2110        G: crypto::signature::Signer<crypto::Signature>,
2111        S: ToString,
2112    {
2113        self.transaction("Edit", signer, |tx| tx.edit(title, target))
2114    }
2115
2116    /// Edit revision metadata.
2117    pub fn edit_revision<G, S>(
2118        &mut self,
2119        revision: RevisionId,
2120        description: S,
2121        embeds: impl IntoIterator<Item = Embed<Uri>>,
2122        signer: &Device<G>,
2123    ) -> Result<EntryId, Error>
2124    where
2125        G: crypto::signature::Signer<crypto::Signature>,
2126        S: ToString,
2127    {
2128        self.transaction("Edit revision", signer, |tx| {
2129            tx.edit_revision(revision, description, embeds.into_iter().collect())
2130        })
2131    }
2132
2133    /// Redact a revision.
2134    pub fn redact<G>(&mut self, revision: RevisionId, signer: &Device<G>) -> Result<EntryId, Error>
2135    where
2136        G: crypto::signature::Signer<crypto::Signature>,
2137    {
2138        self.transaction("Redact revision", signer, |tx| tx.redact(revision))
2139    }
2140
2141    /// Create a thread on a patch revision.
2142    pub fn thread<G, S>(
2143        &mut self,
2144        revision: RevisionId,
2145        body: S,
2146        signer: &Device<G>,
2147    ) -> Result<CommentId, Error>
2148    where
2149        G: crypto::signature::Signer<crypto::Signature>,
2150        S: ToString,
2151    {
2152        self.transaction("Create thread", signer, |tx| tx.thread(revision, body))
2153    }
2154
2155    /// Comment on a patch revision.
2156    pub fn comment<G, S>(
2157        &mut self,
2158        revision: RevisionId,
2159        body: S,
2160        reply_to: Option<CommentId>,
2161        location: Option<CodeLocation>,
2162        embeds: impl IntoIterator<Item = Embed<Uri>>,
2163        signer: &Device<G>,
2164    ) -> Result<EntryId, Error>
2165    where
2166        G: crypto::signature::Signer<crypto::Signature>,
2167        S: ToString,
2168    {
2169        self.transaction("Comment", signer, |tx| {
2170            tx.comment(
2171                revision,
2172                body,
2173                reply_to,
2174                location,
2175                embeds.into_iter().collect(),
2176            )
2177        })
2178    }
2179
2180    /// React on a patch revision.
2181    pub fn react<G>(
2182        &mut self,
2183        revision: RevisionId,
2184        reaction: Reaction,
2185        location: Option<CodeLocation>,
2186        active: bool,
2187        signer: &Device<G>,
2188    ) -> Result<EntryId, Error>
2189    where
2190        G: crypto::signature::Signer<crypto::Signature>,
2191    {
2192        self.transaction("React", signer, |tx| {
2193            tx.react(revision, reaction, location, active)
2194        })
2195    }
2196
2197    /// Edit a comment on a patch revision.
2198    pub fn comment_edit<G, S>(
2199        &mut self,
2200        revision: RevisionId,
2201        comment: CommentId,
2202        body: S,
2203        embeds: impl IntoIterator<Item = Embed<Uri>>,
2204        signer: &Device<G>,
2205    ) -> Result<EntryId, Error>
2206    where
2207        G: crypto::signature::Signer<crypto::Signature>,
2208        S: ToString,
2209    {
2210        self.transaction("Edit comment", signer, |tx| {
2211            tx.comment_edit(revision, comment, body, embeds.into_iter().collect())
2212        })
2213    }
2214
2215    /// React to a comment on a patch revision.
2216    pub fn comment_react<G>(
2217        &mut self,
2218        revision: RevisionId,
2219        comment: CommentId,
2220        reaction: Reaction,
2221        active: bool,
2222        signer: &Device<G>,
2223    ) -> Result<EntryId, Error>
2224    where
2225        G: crypto::signature::Signer<crypto::Signature>,
2226    {
2227        self.transaction("React comment", signer, |tx| {
2228            tx.comment_react(revision, comment, reaction, active)
2229        })
2230    }
2231
2232    /// Redact a comment on a patch revision.
2233    pub fn comment_redact<G>(
2234        &mut self,
2235        revision: RevisionId,
2236        comment: CommentId,
2237        signer: &Device<G>,
2238    ) -> Result<EntryId, Error>
2239    where
2240        G: crypto::signature::Signer<crypto::Signature>,
2241    {
2242        self.transaction("Redact comment", signer, |tx| {
2243            tx.comment_redact(revision, comment)
2244        })
2245    }
2246
2247    /// Comment on a line of code as part of a review.
2248    pub fn review_comment<G, S>(
2249        &mut self,
2250        review: ReviewId,
2251        body: S,
2252        location: Option<CodeLocation>,
2253        reply_to: Option<CommentId>,
2254        embeds: impl IntoIterator<Item = Embed<Uri>>,
2255        signer: &Device<G>,
2256    ) -> Result<EntryId, Error>
2257    where
2258        G: crypto::signature::Signer<crypto::Signature>,
2259        S: ToString,
2260    {
2261        self.transaction("Review comment", signer, |tx| {
2262            tx.review_comment(
2263                review,
2264                body,
2265                location,
2266                reply_to,
2267                embeds.into_iter().collect(),
2268            )
2269        })
2270    }
2271
2272    /// Edit review comment.
2273    pub fn edit_review_comment<G, S>(
2274        &mut self,
2275        review: ReviewId,
2276        comment: EntryId,
2277        body: S,
2278        embeds: impl IntoIterator<Item = Embed<Uri>>,
2279        signer: &Device<G>,
2280    ) -> Result<EntryId, Error>
2281    where
2282        G: crypto::signature::Signer<crypto::Signature>,
2283        S: ToString,
2284    {
2285        self.transaction("Edit review comment", signer, |tx| {
2286            tx.edit_review_comment(review, comment, body, embeds.into_iter().collect())
2287        })
2288    }
2289
2290    /// React to a review comment.
2291    pub fn react_review_comment<G>(
2292        &mut self,
2293        review: ReviewId,
2294        comment: EntryId,
2295        reaction: Reaction,
2296        active: bool,
2297        signer: &Device<G>,
2298    ) -> Result<EntryId, Error>
2299    where
2300        G: crypto::signature::Signer<crypto::Signature>,
2301    {
2302        self.transaction("React to review comment", signer, |tx| {
2303            tx.react_review_comment(review, comment, reaction, active)
2304        })
2305    }
2306
2307    /// React to a review comment.
2308    pub fn redact_review_comment<G>(
2309        &mut self,
2310        review: ReviewId,
2311        comment: EntryId,
2312        signer: &Device<G>,
2313    ) -> Result<EntryId, Error>
2314    where
2315        G: crypto::signature::Signer<crypto::Signature>,
2316    {
2317        self.transaction("Redact review comment", signer, |tx| {
2318            tx.redact_review_comment(review, comment)
2319        })
2320    }
2321
2322    /// Review a patch revision.
2323    pub fn review<G>(
2324        &mut self,
2325        revision: RevisionId,
2326        verdict: Option<Verdict>,
2327        summary: Option<String>,
2328        labels: Vec<Label>,
2329        signer: &Device<G>,
2330    ) -> Result<ReviewId, Error>
2331    where
2332        G: crypto::signature::Signer<crypto::Signature>,
2333    {
2334        if verdict.is_none() && summary.is_none() {
2335            return Err(Error::EmptyReview);
2336        }
2337        self.transaction("Review", signer, |tx| {
2338            tx.review(revision, verdict, summary, labels)
2339        })
2340        .map(ReviewId)
2341    }
2342
2343    /// Edit a review.
2344    pub fn review_edit<G>(
2345        &mut self,
2346        review: ReviewId,
2347        verdict: Option<Verdict>,
2348        summary: String,
2349        labels: Vec<Label>,
2350        embeds: impl IntoIterator<Item = Embed<Uri>>,
2351        signer: &Device<G>,
2352    ) -> Result<EntryId, Error>
2353    where
2354        G: crypto::signature::Signer<crypto::Signature>,
2355    {
2356        self.transaction("Edit review", signer, |tx| {
2357            tx.review_edit(review, verdict, summary, labels, embeds)
2358        })
2359    }
2360
2361    /// React to a review.
2362    pub fn review_react<G>(
2363        &mut self,
2364        review: ReviewId,
2365        reaction: Reaction,
2366        active: bool,
2367        signer: &Device<G>,
2368    ) -> Result<EntryId, Error>
2369    where
2370        G: crypto::signature::Signer<crypto::Signature>,
2371    {
2372        self.transaction("React to review", signer, |tx| {
2373            tx.review_react(review, reaction, active)
2374        })
2375    }
2376
2377    /// Redact a patch review.
2378    pub fn redact_review<G>(
2379        &mut self,
2380        review: ReviewId,
2381        signer: &Device<G>,
2382    ) -> Result<EntryId, Error>
2383    where
2384        G: crypto::signature::Signer<crypto::Signature>,
2385    {
2386        self.transaction("Redact review", signer, |tx| tx.redact_review(review))
2387    }
2388
2389    /// Resolve a patch review comment.
2390    pub fn resolve_review_comment<G>(
2391        &mut self,
2392        review: ReviewId,
2393        comment: CommentId,
2394        signer: &Device<G>,
2395    ) -> Result<EntryId, Error>
2396    where
2397        G: crypto::signature::Signer<crypto::Signature>,
2398    {
2399        self.transaction("Resolve review comment", signer, |tx| {
2400            tx.review_comment_resolve(review, comment)
2401        })
2402    }
2403
2404    /// Unresolve a patch review comment.
2405    pub fn unresolve_review_comment<G>(
2406        &mut self,
2407        review: ReviewId,
2408        comment: CommentId,
2409        signer: &Device<G>,
2410    ) -> Result<EntryId, Error>
2411    where
2412        G: crypto::signature::Signer<crypto::Signature>,
2413    {
2414        self.transaction("Unresolve review comment", signer, |tx| {
2415            tx.review_comment_unresolve(review, comment)
2416        })
2417    }
2418
2419    /// Merge a patch revision.
2420    pub fn merge<G>(
2421        &mut self,
2422        revision: RevisionId,
2423        commit: git::Oid,
2424        signer: &Device<G>,
2425    ) -> Result<Merged<'_, R>, Error>
2426    where
2427        G: crypto::signature::Signer<crypto::Signature>,
2428    {
2429        // TODO: Don't allow merging the same revision twice?
2430        let entry = self.transaction("Merge revision", signer, |tx| tx.merge(revision, commit))?;
2431
2432        Ok(Merged {
2433            entry,
2434            patch: self.id,
2435            stored: self.store.as_ref(),
2436        })
2437    }
2438
2439    /// Update a patch with a new revision.
2440    pub fn update<G>(
2441        &mut self,
2442        description: impl ToString,
2443        base: impl Into<git::Oid>,
2444        oid: impl Into<git::Oid>,
2445        signer: &Device<G>,
2446    ) -> Result<RevisionId, Error>
2447    where
2448        G: crypto::signature::Signer<crypto::Signature>,
2449    {
2450        self.transaction("Add revision", signer, |tx| {
2451            tx.revision(description, base, oid)
2452        })
2453        .map(RevisionId)
2454    }
2455
2456    /// Lifecycle a patch.
2457    pub fn lifecycle<G>(&mut self, state: Lifecycle, signer: &Device<G>) -> Result<EntryId, Error>
2458    where
2459        G: crypto::signature::Signer<crypto::Signature>,
2460    {
2461        self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state))
2462    }
2463
2464    /// Assign a patch.
2465    pub fn assign<G>(
2466        &mut self,
2467        assignees: BTreeSet<Did>,
2468        signer: &Device<G>,
2469    ) -> Result<EntryId, Error>
2470    where
2471        G: crypto::signature::Signer<crypto::Signature>,
2472    {
2473        self.transaction("Assign", signer, |tx| tx.assign(assignees))
2474    }
2475
2476    /// Archive a patch.
2477    pub fn archive<G>(&mut self, signer: &Device<G>) -> Result<bool, Error>
2478    where
2479        G: crypto::signature::Signer<crypto::Signature>,
2480    {
2481        self.lifecycle(Lifecycle::Archived, signer)?;
2482
2483        Ok(true)
2484    }
2485
2486    /// Mark an archived patch as ready to be reviewed again.
2487    /// Returns `false` if the patch was not archived.
2488    pub fn unarchive<G>(&mut self, signer: &Device<G>) -> Result<bool, Error>
2489    where
2490        G: crypto::signature::Signer<crypto::Signature>,
2491    {
2492        if !self.is_archived() {
2493            return Ok(false);
2494        }
2495        self.lifecycle(Lifecycle::Open, signer)?;
2496
2497        Ok(true)
2498    }
2499
2500    /// Mark a patch as ready to be reviewed.
2501    /// Returns `false` if the patch was not a draft.
2502    pub fn ready<G>(&mut self, signer: &Device<G>) -> Result<bool, Error>
2503    where
2504        G: crypto::signature::Signer<crypto::Signature>,
2505    {
2506        if !self.is_draft() {
2507            return Ok(false);
2508        }
2509        self.lifecycle(Lifecycle::Open, signer)?;
2510
2511        Ok(true)
2512    }
2513
2514    /// Mark an open patch as a draft.
2515    /// Returns `false` if the patch was not open and free of merges.
2516    pub fn unready<G>(&mut self, signer: &Device<G>) -> Result<bool, Error>
2517    where
2518        G: crypto::signature::Signer<crypto::Signature>,
2519    {
2520        if !matches!(self.state(), State::Open { conflicts } if conflicts.is_empty()) {
2521            return Ok(false);
2522        }
2523        self.lifecycle(Lifecycle::Draft, signer)?;
2524
2525        Ok(true)
2526    }
2527
2528    /// Label a patch.
2529    pub fn label<G>(
2530        &mut self,
2531        labels: impl IntoIterator<Item = Label>,
2532        signer: &Device<G>,
2533    ) -> Result<EntryId, Error>
2534    where
2535        G: crypto::signature::Signer<crypto::Signature>,
2536    {
2537        self.transaction("Label", signer, |tx| tx.label(labels))
2538    }
2539}
2540
2541impl<R, C> Deref for PatchMut<'_, '_, R, C> {
2542    type Target = Patch;
2543
2544    fn deref(&self) -> &Self::Target {
2545        &self.patch
2546    }
2547}
2548
2549/// Detailed information on patch states
2550#[derive(Debug, Default, PartialEq, Eq, Serialize)]
2551#[serde(rename_all = "camelCase")]
2552pub struct PatchCounts {
2553    pub open: usize,
2554    pub draft: usize,
2555    pub archived: usize,
2556    pub merged: usize,
2557}
2558
2559impl PatchCounts {
2560    /// Total count.
2561    pub fn total(&self) -> usize {
2562        self.open + self.draft + self.archived + self.merged
2563    }
2564}
2565
2566/// Result of looking up a `Patch`'s `Revision`.
2567///
2568/// See [`Patches::find_by_revision`].
2569#[derive(Debug, PartialEq, Eq)]
2570pub struct ByRevision {
2571    pub id: PatchId,
2572    pub patch: Patch,
2573    pub revision_id: RevisionId,
2574    pub revision: Revision,
2575}
2576
2577pub struct Patches<'a, R> {
2578    raw: store::Store<'a, Patch, R>,
2579}
2580
2581impl<'a, R> Deref for Patches<'a, R> {
2582    type Target = store::Store<'a, Patch, R>;
2583
2584    fn deref(&self) -> &Self::Target {
2585        &self.raw
2586    }
2587}
2588
2589impl<R> HasRepoId for Patches<'_, R>
2590where
2591    R: ReadRepository,
2592{
2593    fn rid(&self) -> RepoId {
2594        self.as_ref().id()
2595    }
2596}
2597
2598impl<'a, R> Patches<'a, R>
2599where
2600    R: ReadRepository + cob::Store<Namespace = NodeId>,
2601{
2602    /// Open a patches store.
2603    pub fn open(repository: &'a R) -> Result<Self, RepositoryError> {
2604        let identity = repository.identity_head()?;
2605        let raw = store::Store::open(repository)?.identity(identity);
2606
2607        Ok(Self { raw })
2608    }
2609
2610    /// Patches count by state.
2611    pub fn counts(&self) -> Result<PatchCounts, store::Error> {
2612        let all = self.all()?;
2613        let state_groups =
2614            all.filter_map(|s| s.ok())
2615                .fold(PatchCounts::default(), |mut state, (_, p)| {
2616                    match p.state() {
2617                        State::Draft => state.draft += 1,
2618                        State::Open { .. } => state.open += 1,
2619                        State::Archived => state.archived += 1,
2620                        State::Merged { .. } => state.merged += 1,
2621                    }
2622                    state
2623                });
2624
2625        Ok(state_groups)
2626    }
2627
2628    /// Find the `Patch` containing the given `Revision`.
2629    pub fn find_by_revision(&self, revision: &RevisionId) -> Result<Option<ByRevision>, Error> {
2630        // Revision may be the patch's first, making it have the same ID.
2631        let p_id = ObjectId::from(revision.into_inner());
2632        if let Some(p) = self.get(&p_id)? {
2633            return Ok(p.revision(revision).map(|r| ByRevision {
2634                id: p_id,
2635                patch: p.clone(),
2636                revision_id: *revision,
2637                revision: r.clone(),
2638            }));
2639        }
2640        let result = self
2641            .all()?
2642            .filter_map(|result| result.ok())
2643            .find_map(|(p_id, p)| {
2644                p.revision(revision).map(|r| ByRevision {
2645                    id: p_id,
2646                    patch: p.clone(),
2647                    revision_id: *revision,
2648                    revision: r.clone(),
2649                })
2650            });
2651
2652        Ok(result)
2653    }
2654
2655    /// Get a patch.
2656    pub fn get(&self, id: &ObjectId) -> Result<Option<Patch>, store::Error> {
2657        self.raw.get(id)
2658    }
2659
2660    /// Get proposed patches.
2661    pub fn proposed(&self) -> Result<impl Iterator<Item = (PatchId, Patch)> + '_, Error> {
2662        let all = self.all()?;
2663
2664        Ok(all
2665            .into_iter()
2666            .filter_map(|result| result.ok())
2667            .filter(|(_, p)| p.is_open()))
2668    }
2669
2670    /// Get patches proposed by the given key.
2671    pub fn proposed_by<'b>(
2672        &'b self,
2673        who: &'b Did,
2674    ) -> Result<impl Iterator<Item = (PatchId, Patch)> + 'b, Error> {
2675        Ok(self
2676            .proposed()?
2677            .filter(move |(_, p)| p.author().id() == who))
2678    }
2679}
2680
2681impl<'a, R> Patches<'a, R>
2682where
2683    R: ReadRepository + SignRepository + cob::Store<Namespace = NodeId>,
2684{
2685    /// Open a new patch.
2686    pub fn create<'g, C, G>(
2687        &'g mut self,
2688        title: cob::Title,
2689        description: impl ToString,
2690        target: MergeTarget,
2691        base: impl Into<git::Oid>,
2692        oid: impl Into<git::Oid>,
2693        labels: &[Label],
2694        cache: &'g mut C,
2695        signer: &Device<G>,
2696    ) -> Result<PatchMut<'a, 'g, R, C>, Error>
2697    where
2698        C: cob::cache::Update<Patch>,
2699        G: crypto::signature::Signer<crypto::Signature>,
2700    {
2701        self._create(
2702            title,
2703            description,
2704            target,
2705            base,
2706            oid,
2707            labels,
2708            Lifecycle::default(),
2709            cache,
2710            signer,
2711        )
2712    }
2713
2714    /// Draft a patch. This patch will be created in a [`State::Draft`] state.
2715    pub fn draft<'g, C, G>(
2716        &'g mut self,
2717        title: cob::Title,
2718        description: impl ToString,
2719        target: MergeTarget,
2720        base: impl Into<git::Oid>,
2721        oid: impl Into<git::Oid>,
2722        labels: &[Label],
2723        cache: &'g mut C,
2724        signer: &Device<G>,
2725    ) -> Result<PatchMut<'a, 'g, R, C>, Error>
2726    where
2727        C: cob::cache::Update<Patch>,
2728        G: crypto::signature::Signer<crypto::Signature>,
2729    {
2730        self._create(
2731            title,
2732            description,
2733            target,
2734            base,
2735            oid,
2736            labels,
2737            Lifecycle::Draft,
2738            cache,
2739            signer,
2740        )
2741    }
2742
2743    /// Get a patch mutably.
2744    pub fn get_mut<'g, C>(
2745        &'g mut self,
2746        id: &ObjectId,
2747        cache: &'g mut C,
2748    ) -> Result<PatchMut<'a, 'g, R, C>, store::Error> {
2749        let patch = self
2750            .raw
2751            .get(id)?
2752            .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
2753
2754        Ok(PatchMut {
2755            id: *id,
2756            patch,
2757            store: self,
2758            cache,
2759        })
2760    }
2761
2762    /// Create a patch. This is an internal function used by `create` and `draft`.
2763    fn _create<'g, C, G>(
2764        &'g mut self,
2765        title: cob::Title,
2766        description: impl ToString,
2767        target: MergeTarget,
2768        base: impl Into<git::Oid>,
2769        oid: impl Into<git::Oid>,
2770        labels: &[Label],
2771        state: Lifecycle,
2772        cache: &'g mut C,
2773        signer: &Device<G>,
2774    ) -> Result<PatchMut<'a, 'g, R, C>, Error>
2775    where
2776        C: cob::cache::Update<Patch>,
2777        G: crypto::signature::Signer<crypto::Signature>,
2778    {
2779        let (id, patch) = Transaction::initial("Create patch", &mut self.raw, signer, |tx, _| {
2780            tx.revision(description, base, oid)?;
2781            tx.edit(title, target)?;
2782
2783            if !labels.is_empty() {
2784                tx.label(labels.to_owned())?;
2785            }
2786            if state != Lifecycle::default() {
2787                tx.lifecycle(state)?;
2788            }
2789            Ok(())
2790        })?;
2791        cache
2792            .update(&self.raw.as_ref().id(), &id, &patch)
2793            .map_err(|e| Error::CacheUpdate { id, err: e.into() })?;
2794
2795        Ok(PatchMut {
2796            id,
2797            patch,
2798            store: self,
2799            cache,
2800        })
2801    }
2802}
2803
2804/// Models a comparison between two commit ranges,
2805/// commonly obtained from two revisions (likely of the same patch).
2806/// This can be used to generate a `git range-diff` command.
2807/// See <https://git-scm.com/docs/git-range-diff>.
2808#[derive(Debug, Clone, PartialEq, Eq, Copy)]
2809pub struct RangeDiff {
2810    old: (git::Oid, git::Oid),
2811    new: (git::Oid, git::Oid),
2812}
2813
2814impl RangeDiff {
2815    const COMMAND: &str = "git";
2816    const SUBCOMMAND: &str = "range-diff";
2817
2818    pub fn new(old: &Revision, new: &Revision) -> Self {
2819        Self {
2820            old: old.range(),
2821            new: new.range(),
2822        }
2823    }
2824
2825    pub fn to_command(&self) -> String {
2826        let range = if self.has_same_base() {
2827            format!("{} {} {}", self.old.0, self.old.1, self.new.1)
2828        } else {
2829            format!(
2830                "{}..{} {}..{}",
2831                self.old.0, self.old.1, self.new.0, self.new.1,
2832            )
2833        };
2834
2835        Self::COMMAND.to_string() + " " + Self::SUBCOMMAND + " " + &range
2836    }
2837
2838    fn has_same_base(&self) -> bool {
2839        self.old.0 == self.new.0
2840    }
2841}
2842
2843impl From<RangeDiff> for std::process::Command {
2844    fn from(range_diff: RangeDiff) -> Self {
2845        let mut command = std::process::Command::new(RangeDiff::COMMAND);
2846
2847        command.arg(RangeDiff::SUBCOMMAND);
2848
2849        if range_diff.has_same_base() {
2850            command.args([
2851                range_diff.old.0.to_string(),
2852                range_diff.old.1.to_string(),
2853                range_diff.new.1.to_string(),
2854            ]);
2855        } else {
2856            command.args([
2857                format!("{}..{}", range_diff.old.0, range_diff.old.1),
2858                format!("{}..{}", range_diff.new.0, range_diff.new.1),
2859            ]);
2860        }
2861        command
2862    }
2863}
2864
2865/// Helpers for de/serialization of patch data types.
2866mod ser {
2867    use std::collections::{BTreeMap, BTreeSet};
2868
2869    use serde::ser::SerializeSeq;
2870
2871    use crate::cob::{thread::Reactions, ActorId, CodeLocation};
2872
2873    /// Serialize a `Revision`'s reaction as an object containing the
2874    /// `location`, `emoji`, and all `authors` that have performed the
2875    /// same reaction.
2876    #[derive(Debug, serde::Serialize, serde::Deserialize)]
2877    #[serde(rename_all = "camelCase")]
2878    struct Reaction {
2879        location: Option<CodeLocation>,
2880        emoji: super::Reaction,
2881        authors: Vec<ActorId>,
2882    }
2883
2884    impl Reaction {
2885        fn as_revision_reactions(
2886            reactions: Vec<Reaction>,
2887        ) -> BTreeMap<Option<CodeLocation>, Reactions> {
2888            reactions.into_iter().fold(
2889                BTreeMap::<Option<CodeLocation>, Reactions>::new(),
2890                |mut reactions,
2891                 Reaction {
2892                     location,
2893                     emoji,
2894                     authors,
2895                 }| {
2896                    let mut inner = authors
2897                        .into_iter()
2898                        .map(|author| (author, emoji))
2899                        .collect::<BTreeSet<_>>();
2900                    let entry = reactions.entry(location).or_default();
2901                    entry.append(&mut inner);
2902                    reactions
2903                },
2904            )
2905        }
2906    }
2907
2908    /// Helper to serialize a `Revision`'s reactions, since
2909    /// `CodeLocation` cannot be a key for a JSON object.
2910    ///
2911    /// The set `reactions` are first turned into a set of
2912    /// [`Reaction`]s and then serialized via a `Vec`.
2913    pub fn serialize_reactions<S>(
2914        reactions: &BTreeMap<Option<CodeLocation>, Reactions>,
2915        serializer: S,
2916    ) -> Result<S::Ok, S::Error>
2917    where
2918        S: serde::Serializer,
2919    {
2920        let reactions = reactions
2921            .iter()
2922            .flat_map(|(location, reaction)| {
2923                let reactions = reaction.iter().fold(
2924                    BTreeMap::new(),
2925                    |mut acc: BTreeMap<&super::Reaction, Vec<_>>, (author, emoji)| {
2926                        acc.entry(emoji).or_default().push(*author);
2927                        acc
2928                    },
2929                );
2930                reactions
2931                    .into_iter()
2932                    .map(|(emoji, authors)| Reaction {
2933                        location: location.clone(),
2934                        emoji: *emoji,
2935                        authors,
2936                    })
2937                    .collect::<Vec<_>>()
2938            })
2939            .collect::<Vec<_>>();
2940        let mut s = serializer.serialize_seq(Some(reactions.len()))?;
2941        for r in &reactions {
2942            s.serialize_element(r)?;
2943        }
2944        s.end()
2945    }
2946
2947    /// Helper to deserialize a `Revision`'s reactions, the inverse of
2948    /// `serialize_reactions`.
2949    ///
2950    /// The `Vec` of [`Reaction`]s are deserialized and converted to a
2951    /// `BTreeMap<Option<CodeLocation>, Reactions>`.
2952    pub fn deserialize_reactions<'de, D>(
2953        deserializer: D,
2954    ) -> Result<BTreeMap<Option<CodeLocation>, Reactions>, D::Error>
2955    where
2956        D: serde::Deserializer<'de>,
2957    {
2958        struct ReactionsVisitor;
2959
2960        impl<'de> serde::de::Visitor<'de> for ReactionsVisitor {
2961            type Value = Vec<Reaction>;
2962
2963            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
2964                formatter.write_str("a reaction of the form {'location', 'emoji', 'authors'}")
2965            }
2966
2967            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
2968            where
2969                A: serde::de::SeqAccess<'de>,
2970            {
2971                let mut reactions = Vec::new();
2972                while let Some(reaction) = seq.next_element()? {
2973                    reactions.push(reaction);
2974                }
2975                Ok(reactions)
2976            }
2977        }
2978
2979        let reactions = deserializer.deserialize_seq(ReactionsVisitor)?;
2980        Ok(Reaction::as_revision_reactions(reactions))
2981    }
2982}
2983
2984#[cfg(test)]
2985#[allow(clippy::unwrap_used)]
2986mod test {
2987    use std::path::PathBuf;
2988    use std::str::FromStr;
2989    use std::vec;
2990
2991    use pretty_assertions::assert_eq;
2992
2993    use super::*;
2994    use crate::cob::common::CodeRange;
2995    use crate::cob::test::Actor;
2996    use crate::crypto::test::signer::MockSigner;
2997    use crate::identity;
2998    use crate::patch::cache::Patches as _;
2999    use crate::profile::env;
3000    use crate::test;
3001    use crate::test::arbitrary;
3002    use crate::test::arbitrary::gen;
3003    use crate::test::storage::MockRepository;
3004
3005    use cob::migrate;
3006
3007    #[test]
3008    fn test_json_serialization() {
3009        let edit = Action::Label {
3010            labels: BTreeSet::new(),
3011        };
3012        assert_eq!(
3013            serde_json::to_string(&edit).unwrap(),
3014            String::from(r#"{"type":"label","labels":[]}"#)
3015        );
3016    }
3017
3018    #[test]
3019    fn test_reactions_json_serialization() {
3020        #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
3021        #[serde(rename_all = "camelCase")]
3022        struct TestReactions {
3023            #[serde(
3024                serialize_with = "super::ser::serialize_reactions",
3025                deserialize_with = "super::ser::deserialize_reactions"
3026            )]
3027            inner: BTreeMap<Option<CodeLocation>, Reactions>,
3028        }
3029
3030        let reactions = TestReactions {
3031            inner: [(
3032                None,
3033                [
3034                    (
3035                        "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"
3036                            .parse()
3037                            .unwrap(),
3038                        Reaction::new('🚀').unwrap(),
3039                    ),
3040                    (
3041                        "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
3042                            .parse()
3043                            .unwrap(),
3044                        Reaction::new('🙏').unwrap(),
3045                    ),
3046                ]
3047                .into_iter()
3048                .collect(),
3049            )]
3050            .into_iter()
3051            .collect(),
3052        };
3053
3054        assert_eq!(
3055            reactions,
3056            serde_json::from_str(&serde_json::to_string(&reactions).unwrap()).unwrap()
3057        );
3058    }
3059
3060    #[test]
3061    fn test_patch_create_and_get() {
3062        let alice = test::setup::NodeWithRepo::default();
3063        let checkout = alice.repo.checkout();
3064        let branch = checkout.branch_with([("README", b"Hello World!")]);
3065        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3066        let author: Did = alice.signer.public_key().into();
3067        let target = MergeTarget::Delegates;
3068        let patch = patches
3069            .create(
3070                cob::Title::new("My first patch").unwrap(),
3071                "Blah blah blah.",
3072                target,
3073                branch.base,
3074                branch.oid,
3075                &[],
3076                &alice.signer,
3077            )
3078            .unwrap();
3079
3080        let patch_id = patch.id;
3081        let patch = patches.get(&patch_id).unwrap().unwrap();
3082
3083        assert_eq!(patch.title(), "My first patch");
3084        assert_eq!(patch.description(), "Blah blah blah.");
3085        assert_eq!(patch.author().id(), &author);
3086        assert_eq!(patch.state(), &State::Open { conflicts: vec![] });
3087        assert_eq!(patch.target(), target);
3088        assert_eq!(patch.version(), 0);
3089
3090        let (rev_id, revision) = patch.latest();
3091
3092        assert_eq!(revision.author.id(), &author);
3093        assert_eq!(revision.description(), "Blah blah blah.");
3094        assert_eq!(revision.discussion.len(), 0);
3095        assert_eq!(revision.oid, branch.oid);
3096        assert_eq!(revision.base, branch.base);
3097
3098        let ByRevision { id, .. } = patches.find_by_revision(&rev_id).unwrap().unwrap();
3099        assert_eq!(id, patch_id);
3100    }
3101
3102    #[test]
3103    fn test_patch_discussion() {
3104        let alice = test::setup::NodeWithRepo::default();
3105        let checkout = alice.repo.checkout();
3106        let branch = checkout.branch_with([("README", b"Hello World!")]);
3107        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3108        let patch = patches
3109            .create(
3110                cob::Title::new("My first patch").unwrap(),
3111                "Blah blah blah.",
3112                MergeTarget::Delegates,
3113                branch.base,
3114                branch.oid,
3115                &[],
3116                &alice.signer,
3117            )
3118            .unwrap();
3119
3120        let id = patch.id;
3121        let mut patch = patches.get_mut(&id).unwrap();
3122        let (revision_id, _) = patch.revisions().last().unwrap();
3123        assert!(
3124            patch
3125                .comment(revision_id, "patch comment", None, None, [], &alice.signer)
3126                .is_ok(),
3127            "can comment on patch"
3128        );
3129
3130        let (_, revision) = patch.revisions().last().unwrap();
3131        let (_, comment) = revision.discussion.first().unwrap();
3132        assert_eq!("patch comment", comment.body(), "comment body untouched");
3133    }
3134
3135    #[test]
3136    fn test_patch_merge() {
3137        let alice = test::setup::NodeWithRepo::default();
3138        let checkout = alice.repo.checkout();
3139        let branch = checkout.branch_with([("README", b"Hello World!")]);
3140        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3141        let mut patch = patches
3142            .create(
3143                cob::Title::new("My first patch").unwrap(),
3144                "Blah blah blah.",
3145                MergeTarget::Delegates,
3146                branch.base,
3147                branch.oid,
3148                &[],
3149                &alice.signer,
3150            )
3151            .unwrap();
3152
3153        let id = patch.id;
3154        let (rid, _) = patch.revisions().next().unwrap();
3155        let _merge = patch.merge(rid, branch.base, &alice.signer).unwrap();
3156        let patch = patches.get(&id).unwrap().unwrap();
3157
3158        let merges = patch.merges.iter().collect::<Vec<_>>();
3159        assert_eq!(merges.len(), 1);
3160
3161        let (merger, merge) = merges.first().unwrap();
3162        assert_eq!(*merger, alice.signer.public_key());
3163        assert_eq!(merge.commit, branch.base);
3164    }
3165
3166    #[test]
3167    fn test_patch_review() {
3168        let alice = test::setup::NodeWithRepo::default();
3169        let checkout = alice.repo.checkout();
3170        let branch = checkout.branch_with([("README", b"Hello World!")]);
3171        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3172        let mut patch = patches
3173            .create(
3174                cob::Title::new("My first patch").unwrap(),
3175                "Blah blah blah.",
3176                MergeTarget::Delegates,
3177                branch.base,
3178                branch.oid,
3179                &[],
3180                &alice.signer,
3181            )
3182            .unwrap();
3183
3184        let (revision_id, _) = patch.latest();
3185        let review_id = patch
3186            .review(
3187                revision_id,
3188                Some(Verdict::Accept),
3189                Some("LGTM".to_owned()),
3190                vec![],
3191                &alice.signer,
3192            )
3193            .unwrap();
3194
3195        let id = patch.id;
3196        let mut patch = patches.get_mut(&id).unwrap();
3197        let (_, revision) = patch.latest();
3198        assert_eq!(revision.reviews.len(), 1);
3199
3200        let review = revision.review_by(alice.signer.public_key()).unwrap();
3201        assert_eq!(review.verdict(), Some(Verdict::Accept));
3202        assert_eq!(review.summary(), "LGTM");
3203
3204        patch.redact_review(review_id, &alice.signer).unwrap();
3205        patch.reload().unwrap();
3206
3207        let (_, revision) = patch.latest();
3208        assert_eq!(revision.reviews().count(), 0);
3209
3210        // This is fine, redacting an already-redacted review is a no-op.
3211        patch.redact_review(review_id, &alice.signer).unwrap();
3212        // If the review never existed, it's an error.
3213        patch
3214            .redact_review(ReviewId(arbitrary::entry_id()), &alice.signer)
3215            .unwrap_err();
3216    }
3217
3218    #[test]
3219    fn test_patch_review_revision_redact() {
3220        let alice = test::setup::NodeWithRepo::default();
3221        let checkout = alice.repo.checkout();
3222        let branch = checkout.branch_with([("README", b"Hello World!")]);
3223        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3224        let mut patch = patches
3225            .create(
3226                cob::Title::new("My first patch").unwrap(),
3227                "Blah blah blah.",
3228                MergeTarget::Delegates,
3229                branch.base,
3230                branch.oid,
3231                &[],
3232                &alice.signer,
3233            )
3234            .unwrap();
3235
3236        let update = checkout.branch_with([("README", b"Hello Radicle!")]);
3237        let updated = patch
3238            .update("I've made changes.", branch.base, update.oid, &alice.signer)
3239            .unwrap();
3240
3241        // It's fine to redact a review from a redacted revision.
3242        let review = patch
3243            .review(updated, Some(Verdict::Accept), None, vec![], &alice.signer)
3244            .unwrap();
3245        patch.redact(updated, &alice.signer).unwrap();
3246        patch.redact_review(review, &alice.signer).unwrap();
3247    }
3248
3249    #[test]
3250    fn test_revision_review_merge_redacted() {
3251        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
3252        let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
3253        let mut alice = Actor::<MockSigner>::default();
3254        let rid = gen::<RepoId>(1);
3255        let doc = RawDoc::new(
3256            gen::<Project>(1),
3257            vec![alice.did()],
3258            1,
3259            identity::Visibility::Public,
3260        )
3261        .verified()
3262        .unwrap();
3263        let repo = MockRepository::new(rid, doc);
3264
3265        let a1 = alice.op::<Patch>([
3266            Action::Revision {
3267                description: String::new(),
3268                base,
3269                oid,
3270                resolves: Default::default(),
3271            },
3272            Action::Edit {
3273                title: cob::Title::new("My patch").unwrap(),
3274                target: MergeTarget::Delegates,
3275            },
3276        ]);
3277        let a2 = alice.op::<Patch>([Action::Revision {
3278            description: String::from("Second revision"),
3279            base,
3280            oid,
3281            resolves: Default::default(),
3282        }]);
3283        let a3 = alice.op::<Patch>([Action::RevisionRedact {
3284            revision: RevisionId(a2.id()),
3285        }]);
3286        let a4 = alice.op::<Patch>([Action::Review {
3287            revision: RevisionId(a2.id()),
3288            summary: None,
3289            verdict: Some(Verdict::Accept),
3290            labels: vec![],
3291        }]);
3292        let a5 = alice.op::<Patch>([Action::Merge {
3293            revision: RevisionId(a2.id()),
3294            commit: oid,
3295        }]);
3296
3297        let mut patch = Patch::from_ops([a1, a2], &repo).unwrap();
3298        assert_eq!(patch.revisions().count(), 2);
3299
3300        patch.op(a3, [], &repo).unwrap();
3301        assert_eq!(patch.revisions().count(), 1);
3302
3303        patch.op(a4, [], &repo).unwrap();
3304        patch.op(a5, [], &repo).unwrap();
3305    }
3306
3307    #[test]
3308    fn test_revision_edit_redact() {
3309        let base = arbitrary::oid();
3310        let oid = arbitrary::oid();
3311        let repo = gen::<MockRepository>(1);
3312        let time = env::local_time();
3313        let alice = MockSigner::default();
3314        let bob = MockSigner::default();
3315        let mut h0: cob::test::HistoryBuilder<Patch> = cob::test::history(
3316            &[
3317                Action::Revision {
3318                    description: String::from("Original"),
3319                    base,
3320                    oid,
3321                    resolves: Default::default(),
3322                },
3323                Action::Edit {
3324                    title: cob::Title::new("Some patch").unwrap(),
3325                    target: MergeTarget::Delegates,
3326                },
3327            ],
3328            time.into(),
3329            &alice,
3330        );
3331        let r1 = h0.commit(
3332            &Action::Revision {
3333                description: String::from("New"),
3334                base,
3335                oid,
3336                resolves: Default::default(),
3337            },
3338            &alice,
3339        );
3340        let patch: Patch = Patch::from_history(&h0, &repo).unwrap();
3341        assert_eq!(patch.revisions().count(), 2);
3342
3343        let mut h1 = h0.clone();
3344        h1.commit(
3345            &Action::RevisionRedact {
3346                revision: RevisionId(r1),
3347            },
3348            &alice,
3349        );
3350
3351        let mut h2 = h0.clone();
3352        h2.commit(
3353            &Action::RevisionEdit {
3354                revision: RevisionId(*h0.root().id()),
3355                description: String::from("Edited"),
3356                embeds: Vec::default(),
3357            },
3358            &bob,
3359        );
3360
3361        h0.merge(h1);
3362        h0.merge(h2);
3363
3364        let patch = Patch::from_history(&h0, &repo).unwrap();
3365        assert_eq!(patch.revisions().count(), 1);
3366    }
3367
3368    #[test]
3369    fn test_revision_reaction() {
3370        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
3371        let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
3372        let mut alice = Actor::<MockSigner>::default();
3373        let repo = gen::<MockRepository>(1);
3374        let reaction = Reaction::new('👍').expect("failed to create a reaction");
3375
3376        let a1 = alice.op::<Patch>([
3377            Action::Revision {
3378                description: String::new(),
3379                base,
3380                oid,
3381                resolves: Default::default(),
3382            },
3383            Action::Edit {
3384                title: cob::Title::new("My patch").unwrap(),
3385                target: MergeTarget::Delegates,
3386            },
3387        ]);
3388        let a2 = alice.op::<Patch>([Action::RevisionReact {
3389            revision: RevisionId(a1.id()),
3390            location: None,
3391            reaction,
3392            active: true,
3393        }]);
3394        let patch = Patch::from_ops([a1, a2], &repo).unwrap();
3395
3396        let (_, r1) = patch.revisions().next().unwrap();
3397        assert!(!r1.reactions.is_empty());
3398
3399        let mut reactions = r1.reactions.get(&None).unwrap().clone();
3400        assert!(!reactions.is_empty());
3401
3402        let (_, first_reaction) = reactions.pop_first().unwrap();
3403        assert_eq!(first_reaction, reaction);
3404    }
3405
3406    #[test]
3407    fn test_patch_review_edit() {
3408        let alice = test::setup::NodeWithRepo::default();
3409        let checkout = alice.repo.checkout();
3410        let branch = checkout.branch_with([("README", b"Hello World!")]);
3411        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3412        let mut patch = patches
3413            .create(
3414                cob::Title::new("My first patch").unwrap(),
3415                "Blah blah blah.",
3416                MergeTarget::Delegates,
3417                branch.base,
3418                branch.oid,
3419                &[],
3420                &alice.signer,
3421            )
3422            .unwrap();
3423
3424        let (rid, _) = patch.latest();
3425        let review = patch
3426            .review(
3427                rid,
3428                Some(Verdict::Accept),
3429                Some("LGTM".to_owned()),
3430                vec![],
3431                &alice.signer,
3432            )
3433            .unwrap();
3434        patch
3435            .review_edit(
3436                review,
3437                Some(Verdict::Reject),
3438                "Whoops!".to_owned(),
3439                vec![],
3440                vec![],
3441                &alice.signer,
3442            )
3443            .unwrap(); // Overwrite the comment.
3444
3445        let (_, revision) = patch.latest();
3446        let review = revision.review_by(alice.signer.public_key()).unwrap();
3447        assert_eq!(review.verdict(), Some(Verdict::Reject));
3448        assert_eq!(review.summary(), "Whoops!");
3449    }
3450
3451    #[test]
3452    fn test_patch_review_duplicate() {
3453        let alice = test::setup::NodeWithRepo::default();
3454        let checkout = alice.repo.checkout();
3455        let branch = checkout.branch_with([("README", b"Hello World!")]);
3456        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3457        let mut patch = patches
3458            .create(
3459                cob::Title::new("My first patch").unwrap(),
3460                "Blah blah blah.",
3461                MergeTarget::Delegates,
3462                branch.base,
3463                branch.oid,
3464                &[],
3465                &alice.signer,
3466            )
3467            .unwrap();
3468
3469        let (rid, _) = patch.latest();
3470        patch
3471            .review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
3472            .unwrap();
3473        patch
3474            .review(rid, Some(Verdict::Reject), None, vec![], &alice.signer)
3475            .unwrap(); // This review is ignored, since there is already a review by this author.
3476
3477        let (_, revision) = patch.latest();
3478        let review = revision.review_by(alice.signer.public_key()).unwrap();
3479        assert_eq!(review.verdict(), Some(Verdict::Accept));
3480    }
3481
3482    #[test]
3483    fn test_patch_review_edit_comment() {
3484        let alice = test::setup::NodeWithRepo::default();
3485        let checkout = alice.repo.checkout();
3486        let branch = checkout.branch_with([("README", b"Hello World!")]);
3487        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3488        let mut patch = patches
3489            .create(
3490                cob::Title::new("My first patch").unwrap(),
3491                "Blah blah blah.",
3492                MergeTarget::Delegates,
3493                branch.base,
3494                branch.oid,
3495                &[],
3496                &alice.signer,
3497            )
3498            .unwrap();
3499
3500        let (rid, _) = patch.latest();
3501        let review = patch
3502            .review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
3503            .unwrap();
3504        patch
3505            .review_comment(review, "First comment!", None, None, [], &alice.signer)
3506            .unwrap();
3507
3508        let _review = patch
3509            .review_edit(
3510                review,
3511                Some(Verdict::Reject),
3512                "".to_string(),
3513                vec![],
3514                vec![],
3515                &alice.signer,
3516            )
3517            .unwrap();
3518        patch
3519            .review_comment(review, "Second comment!", None, None, [], &alice.signer)
3520            .unwrap();
3521
3522        let (_, revision) = patch.latest();
3523        let review = revision.review_by(alice.signer.public_key()).unwrap();
3524        assert_eq!(review.verdict(), Some(Verdict::Reject));
3525        assert_eq!(review.comments().count(), 2);
3526        assert_eq!(review.comments().nth(0).unwrap().1.body(), "First comment!");
3527        assert_eq!(
3528            review.comments().nth(1).unwrap().1.body(),
3529            "Second comment!"
3530        );
3531    }
3532
3533    #[test]
3534    fn test_patch_review_comment() {
3535        let alice = test::setup::NodeWithRepo::default();
3536        let checkout = alice.repo.checkout();
3537        let branch = checkout.branch_with([("README", b"Hello World!")]);
3538        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3539        let mut patch = patches
3540            .create(
3541                cob::Title::new("My first patch").unwrap(),
3542                "Blah blah blah.",
3543                MergeTarget::Delegates,
3544                branch.base,
3545                branch.oid,
3546                &[],
3547                &alice.signer,
3548            )
3549            .unwrap();
3550
3551        let (rid, _) = patch.latest();
3552        let location = CodeLocation {
3553            commit: branch.oid,
3554            path: PathBuf::from_str("README").unwrap(),
3555            old: None,
3556            new: Some(CodeRange::Lines { range: 5..8 }),
3557        };
3558        let review = patch
3559            .review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
3560            .unwrap();
3561        patch
3562            .review_comment(
3563                review,
3564                "I like these lines of code",
3565                Some(location.clone()),
3566                None,
3567                [],
3568                &alice.signer,
3569            )
3570            .unwrap();
3571
3572        let (_, revision) = patch.latest();
3573        let review = revision.review_by(alice.signer.public_key()).unwrap();
3574        let (_, comment) = review.comments().next().unwrap();
3575
3576        assert_eq!(comment.body(), "I like these lines of code");
3577        assert_eq!(comment.location(), Some(&location));
3578    }
3579
3580    #[test]
3581    fn test_patch_review_remove_summary() {
3582        let alice = test::setup::NodeWithRepo::default();
3583        let checkout = alice.repo.checkout();
3584        let branch = checkout.branch_with([("README", b"Hello World!")]);
3585        let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3586        let mut patch = patches
3587            .create(
3588                cob::Title::new("My first patch").unwrap(),
3589                "Blah blah blah.",
3590                MergeTarget::Delegates,
3591                branch.base,
3592                branch.oid,
3593                &[],
3594                &alice.signer,
3595            )
3596            .unwrap();
3597
3598        let (rid, _) = patch.latest();
3599        let review = patch
3600            .review(
3601                rid,
3602                Some(Verdict::Accept),
3603                Some("Nah".to_owned()),
3604                vec![],
3605                &alice.signer,
3606            )
3607            .unwrap();
3608        patch
3609            .review_edit(
3610                review,
3611                Some(Verdict::Accept),
3612                "".to_string(),
3613                vec![],
3614                vec![],
3615                &alice.signer,
3616            )
3617            .unwrap();
3618
3619        let id = patch.id;
3620        let patch = patches.get_mut(&id).unwrap();
3621        let (_, revision) = patch.latest();
3622        let review = revision.review_by(alice.signer.public_key()).unwrap();
3623
3624        assert_eq!(review.verdict(), Some(Verdict::Accept));
3625        assert_eq!(review.summary(), "");
3626    }
3627
3628    #[test]
3629    fn test_patch_update() {
3630        let alice = test::setup::NodeWithRepo::default();
3631        let checkout = alice.repo.checkout();
3632        let branch = checkout.branch_with([("README", b"Hello World!")]);
3633        let mut patches = {
3634            let path = alice.tmp.path().join("cobs.db");
3635            let mut db = cob::cache::Store::open(path).unwrap();
3636            let store = cob::patch::Patches::open(&*alice.repo).unwrap();
3637
3638            db.migrate(migrate::ignore).unwrap();
3639            cob::patch::Cache::open(store, db)
3640        };
3641        let mut patch = patches
3642            .create(
3643                cob::Title::new("My first patch").unwrap(),
3644                "Blah blah blah.",
3645                MergeTarget::Delegates,
3646                branch.base,
3647                branch.oid,
3648                &[],
3649                &alice.signer,
3650            )
3651            .unwrap();
3652
3653        assert_eq!(patch.description(), "Blah blah blah.");
3654        assert_eq!(patch.version(), 0);
3655
3656        let update = checkout.branch_with([("README", b"Hello Radicle!")]);
3657        let _ = patch
3658            .update("I've made changes.", branch.base, update.oid, &alice.signer)
3659            .unwrap();
3660
3661        let id = patch.id;
3662        let patch = patches.get(&id).unwrap().unwrap();
3663        assert_eq!(patch.version(), 1);
3664        assert_eq!(patch.revisions.len(), 2);
3665        assert_eq!(patch.revisions().count(), 2);
3666        assert_eq!(
3667            patch.revisions().nth(0).unwrap().1.description(),
3668            "Blah blah blah."
3669        );
3670        assert_eq!(
3671            patch.revisions().nth(1).unwrap().1.description(),
3672            "I've made changes."
3673        );
3674
3675        let (_, revision) = patch.latest();
3676
3677        assert_eq!(patch.version(), 1);
3678        assert_eq!(revision.oid, update.oid);
3679        assert_eq!(revision.description(), "I've made changes.");
3680    }
3681
3682    #[test]
3683    fn test_patch_redact() {
3684        let alice = test::setup::Node::default();
3685        let repo = alice.project();
3686        let branch = repo
3687            .checkout()
3688            .branch_with([("README.md", b"Hello, World!")]);
3689        let mut patches = Cache::no_cache(&*repo).unwrap();
3690        let mut patch = patches
3691            .create(
3692                cob::Title::new("My first patch").unwrap(),
3693                "Blah blah blah.",
3694                MergeTarget::Delegates,
3695                branch.base,
3696                branch.oid,
3697                &[],
3698                &alice.signer,
3699            )
3700            .unwrap();
3701        let patch_id = patch.id;
3702
3703        let update = repo
3704            .checkout()
3705            .branch_with([("README.md", b"Hello, Radicle!")]);
3706        let revision_id = patch
3707            .update("I've made changes.", branch.base, update.oid, &alice.signer)
3708            .unwrap();
3709        assert_eq!(patch.revisions().count(), 2);
3710
3711        patch.redact(revision_id, &alice.signer).unwrap();
3712        assert_eq!(patch.latest().0, RevisionId(*patch_id));
3713        assert_eq!(patch.revisions().count(), 1);
3714
3715        // The patch's root must always exist.
3716        assert_eq!(patch.latest(), patch.root());
3717        assert!(patch.redact(patch.latest().0, &alice.signer).is_err());
3718    }
3719
3720    #[test]
3721    fn test_json() {
3722        use serde_json::json;
3723
3724        assert_eq!(
3725            serde_json::to_value(Action::Lifecycle {
3726                state: Lifecycle::Draft
3727            })
3728            .unwrap(),
3729            json!({
3730                "type": "lifecycle",
3731                "state": { "status": "draft" }
3732            })
3733        );
3734
3735        let revision = RevisionId(arbitrary::entry_id());
3736        assert_eq!(
3737            serde_json::to_value(Action::Review {
3738                revision,
3739                summary: None,
3740                verdict: None,
3741                labels: vec![],
3742            })
3743            .unwrap(),
3744            json!({
3745                "type": "review",
3746                "revision": revision,
3747            })
3748        );
3749
3750        assert_eq!(
3751            serde_json::to_value(CodeRange::Lines { range: 4..8 }).unwrap(),
3752            json!({
3753                "type": "lines",
3754                "range": { "start": 4, "end": 8 },
3755            })
3756        );
3757    }
3758}