1pub mod cache;
2
3use std::collections::btree_map;
4use std::collections::{BTreeMap, BTreeSet, HashMap};
5use std::fmt;
6use std::ops::Deref;
7use std::str::FromStr;
8
9use amplify::Wrapper;
10use nonempty::NonEmpty;
11use once_cell::sync::Lazy;
12use serde::{Deserialize, Serialize};
13use storage::{HasRepoId, RepositoryError};
14use thiserror::Error;
15
16use crate::cob;
17use crate::cob::common::{Author, Authorization, CodeLocation, Label, Reaction, Timestamp};
18use crate::cob::store::Transaction;
19use crate::cob::store::{Cob, CobAction};
20use crate::cob::thread;
21use crate::cob::thread::Thread;
22use crate::cob::thread::{Comment, CommentId, Edit, Reactions};
23use crate::cob::{op, store, ActorId, Embed, EntryId, ObjectId, TypeName, Uri};
24use crate::crypto::{PublicKey, Signer};
25use crate::git;
26use crate::identity::doc::{DocAt, DocError};
27use crate::identity::PayloadError;
28use crate::prelude::*;
29use crate::storage;
30
31pub use cache::Cache;
32
33pub static TYPENAME: Lazy<TypeName> =
35 Lazy::new(|| FromStr::from_str("xyz.radicle.patch").expect("type name is valid"));
36
37pub type Op = cob::Op<Action>;
39
40pub type PatchId = ObjectId;
42
43#[derive(
45 Wrapper,
46 Debug,
47 Clone,
48 Copy,
49 Serialize,
50 Deserialize,
51 PartialEq,
52 Eq,
53 PartialOrd,
54 Ord,
55 Hash,
56 From,
57 Display,
58)]
59#[display(inner)]
60#[wrap(Deref)]
61pub struct RevisionId(EntryId);
62
63#[derive(
65 Wrapper,
66 Debug,
67 Clone,
68 Copy,
69 Serialize,
70 Deserialize,
71 PartialEq,
72 Eq,
73 PartialOrd,
74 Ord,
75 Hash,
76 From,
77 Display,
78)]
79#[display(inner)]
80#[wrapper(Deref)]
81pub struct ReviewId(EntryId);
82
83pub type RevisionIx = usize;
85
86#[derive(Debug, Error)]
88pub enum Error {
89 #[error("causal dependency {0:?} missing")]
97 Missing(EntryId),
98 #[error("thread apply failed: {0}")]
100 Thread(#[from] thread::Error),
101 #[error("identity doc failed to load: {0}")]
103 Doc(#[from] DocError),
104 #[error("missing identity document")]
106 MissingIdentity,
107 #[error("empty review; verdict or summary not provided")]
109 EmptyReview,
110 #[error("review {0} of {1} already exists by author {2}")]
112 DuplicateReview(ReviewId, RevisionId, NodeId),
113 #[error("payload failed to load: {0}")]
115 Payload(#[from] PayloadError),
116 #[error("git: {0}")]
118 Git(#[from] git::ext::Error),
119 #[error("store: {0}")]
121 Store(#[from] store::Error),
122 #[error("op decoding failed: {0}")]
123 Op(#[from] op::OpEncodingError),
124 #[error("{0} not authorized to apply {1:?}")]
126 NotAuthorized(ActorId, Action),
127 #[error("action is not allowed: {0}")]
129 NotAllowed(EntryId),
130 #[error("revision not found: {0}")]
132 RevisionNotFound(RevisionId),
133 #[error("initialization failed: {0}")]
135 Init(&'static str),
136 #[error("failed to update patch {id} in cache: {err}")]
137 CacheUpdate {
138 id: PatchId,
139 #[source]
140 err: Box<dyn std::error::Error + Send + Sync + 'static>,
141 },
142 #[error("failed to remove patch {id} from cache: {err}")]
143 CacheRemove {
144 id: PatchId,
145 #[source]
146 err: Box<dyn std::error::Error + Send + Sync + 'static>,
147 },
148 #[error("failed to remove patches from cache: {err}")]
149 CacheRemoveAll {
150 #[source]
151 err: Box<dyn std::error::Error + Send + Sync + 'static>,
152 },
153}
154
155#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
157#[serde(tag = "type", rename_all = "camelCase")]
158pub enum Action {
159 #[serde(rename = "edit")]
163 Edit { title: String, target: MergeTarget },
164 #[serde(rename = "label")]
165 Label { labels: BTreeSet<Label> },
166 #[serde(rename = "lifecycle")]
167 Lifecycle { state: Lifecycle },
168 #[serde(rename = "assign")]
169 Assign { assignees: BTreeSet<Did> },
170 #[serde(rename = "merge")]
171 Merge {
172 revision: RevisionId,
173 commit: git::Oid,
174 },
175
176 #[serde(rename = "review")]
180 Review {
181 revision: RevisionId,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 summary: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 verdict: Option<Verdict>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 labels: Vec<Label>,
188 },
189 #[serde(rename = "review.edit")]
190 ReviewEdit {
191 review: ReviewId,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 summary: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 verdict: Option<Verdict>,
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 labels: Vec<Label>,
198 },
199 #[serde(rename = "review.redact")]
200 ReviewRedact { review: ReviewId },
201 #[serde(rename = "review.comment")]
202 ReviewComment {
203 review: ReviewId,
204 body: String,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 location: Option<CodeLocation>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
211 reply_to: Option<CommentId>,
212 #[serde(default, skip_serializing_if = "Vec::is_empty")]
214 embeds: Vec<Embed<Uri>>,
215 },
216 #[serde(rename = "review.comment.edit")]
217 ReviewCommentEdit {
218 review: ReviewId,
219 comment: EntryId,
220 body: String,
221 embeds: Vec<Embed<Uri>>,
222 },
223 #[serde(rename = "review.comment.redact")]
224 ReviewCommentRedact { review: ReviewId, comment: EntryId },
225 #[serde(rename = "review.comment.react")]
226 ReviewCommentReact {
227 review: ReviewId,
228 comment: EntryId,
229 reaction: Reaction,
230 active: bool,
231 },
232 #[serde(rename = "review.comment.resolve")]
233 ReviewCommentResolve { review: ReviewId, comment: EntryId },
234 #[serde(rename = "review.comment.unresolve")]
235 ReviewCommentUnresolve { review: ReviewId, comment: EntryId },
236
237 #[serde(rename = "revision")]
241 Revision {
242 description: String,
243 base: git::Oid,
244 oid: git::Oid,
245 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
247 resolves: BTreeSet<(EntryId, CommentId)>,
248 },
249 #[serde(rename = "revision.edit")]
250 RevisionEdit {
251 revision: RevisionId,
252 description: String,
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 embeds: Vec<Embed<Uri>>,
256 },
257 #[serde(rename = "revision.react")]
259 RevisionReact {
260 revision: RevisionId,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 location: Option<CodeLocation>,
263 reaction: Reaction,
264 active: bool,
265 },
266 #[serde(rename = "revision.redact")]
267 RevisionRedact { revision: RevisionId },
268 #[serde(rename_all = "camelCase")]
269 #[serde(rename = "revision.comment")]
270 RevisionComment {
271 revision: RevisionId,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 location: Option<CodeLocation>,
276 body: String,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
282 reply_to: Option<CommentId>,
283 #[serde(default, skip_serializing_if = "Vec::is_empty")]
285 embeds: Vec<Embed<Uri>>,
286 },
287 #[serde(rename = "revision.comment.edit")]
289 RevisionCommentEdit {
290 revision: RevisionId,
291 comment: CommentId,
292 body: String,
293 embeds: Vec<Embed<Uri>>,
294 },
295 #[serde(rename = "revision.comment.redact")]
297 RevisionCommentRedact {
298 revision: RevisionId,
299 comment: CommentId,
300 },
301 #[serde(rename = "revision.comment.react")]
303 RevisionCommentReact {
304 revision: RevisionId,
305 comment: CommentId,
306 reaction: Reaction,
307 active: bool,
308 },
309}
310
311impl CobAction for Action {
312 fn parents(&self) -> Vec<git::Oid> {
313 match self {
314 Self::Revision { base, oid, .. } => {
315 vec![*base, *oid]
316 }
317 Self::Merge { commit, .. } => {
318 vec![*commit]
319 }
320 _ => vec![],
321 }
322 }
323}
324
325#[derive(Debug)]
327#[must_use]
328pub struct Merged<'a, R> {
329 pub patch: PatchId,
330 pub entry: EntryId,
331
332 stored: &'a R,
333}
334
335impl<'a, R: WriteRepository> Merged<'a, R> {
336 pub fn cleanup<G: Signer>(
341 self,
342 working: &git::raw::Repository,
343 signer: &G,
344 ) -> Result<(), storage::RepositoryError> {
345 let nid = signer.public_key();
346 let stored_ref = git::refs::patch(&self.patch).with_namespace(nid.into());
347 let working_ref = git::refs::workdir::patch_upstream(&self.patch);
348
349 working
350 .find_reference(&working_ref)
351 .and_then(|mut r| r.delete())
352 .ok();
353
354 self.stored
355 .raw()
356 .find_reference(&stored_ref)
357 .and_then(|mut r| r.delete())
358 .ok();
359 self.stored.sign_refs(signer)?;
360
361 Ok(())
362 }
363}
364
365#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
367#[serde(rename_all = "camelCase")]
368pub enum MergeTarget {
369 #[default]
374 Delegates,
375}
376
377impl MergeTarget {
378 pub fn head<R: ReadRepository>(&self, repo: &R) -> Result<git::Oid, RepositoryError> {
380 match self {
381 MergeTarget::Delegates => {
382 let (_, target) = repo.head()?;
383 Ok(target)
384 }
385 }
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(rename_all = "camelCase")]
392pub struct Patch {
393 pub(super) title: String,
395 pub(super) author: Author,
397 pub(super) state: State,
399 pub(super) target: MergeTarget,
401 pub(super) labels: BTreeSet<Label>,
404 pub(super) merges: BTreeMap<ActorId, Merge>,
412 pub(super) revisions: BTreeMap<RevisionId, Option<Revision>>,
417 pub(super) assignees: BTreeSet<ActorId>,
419 pub(super) timeline: Vec<EntryId>,
421 pub(super) reviews: BTreeMap<ReviewId, Option<(RevisionId, ActorId)>>,
423}
424
425impl Patch {
426 pub fn new(title: String, target: MergeTarget, (id, revision): (RevisionId, Revision)) -> Self {
428 Self {
429 title,
430 author: revision.author.clone(),
431 state: State::default(),
432 target,
433 labels: BTreeSet::default(),
434 merges: BTreeMap::default(),
435 revisions: BTreeMap::from_iter([(id, Some(revision))]),
436 assignees: BTreeSet::default(),
437 timeline: vec![id.into_inner()],
438 reviews: BTreeMap::default(),
439 }
440 }
441
442 pub fn title(&self) -> &str {
444 self.title.as_str()
445 }
446
447 pub fn state(&self) -> &State {
449 &self.state
450 }
451
452 pub fn target(&self) -> MergeTarget {
454 self.target
455 }
456
457 pub fn timestamp(&self) -> Timestamp {
459 self.updates()
460 .next()
461 .map(|(_, r)| r)
462 .expect("Patch::timestamp: at least one revision is present")
463 .timestamp
464 }
465
466 pub fn labels(&self) -> impl Iterator<Item = &Label> {
468 self.labels.iter()
469 }
470
471 pub fn description(&self) -> &str {
473 let (_, r) = self.root();
474 r.description()
475 }
476
477 pub fn embeds(&self) -> &[Embed<Uri>] {
479 let (_, r) = self.root();
480 r.embeds()
481 }
482
483 pub fn author(&self) -> &Author {
485 &self.author
486 }
487
488 pub fn authors(&self) -> BTreeSet<&Author> {
490 self.revisions
491 .values()
492 .filter_map(|r| r.as_ref())
493 .map(|r| &r.author)
494 .collect()
495 }
496
497 pub fn revision(&self, id: &RevisionId) -> Option<&Revision> {
501 self.revisions.get(id).and_then(|o| o.as_ref())
502 }
503
504 pub fn updates(&self) -> impl DoubleEndedIterator<Item = (RevisionId, &Revision)> {
507 self.revisions_by(self.author().public_key())
508 }
509
510 pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (RevisionId, &Revision)> {
512 self.timeline.iter().filter_map(move |id| {
513 self.revisions
514 .get(id)
515 .and_then(|o| o.as_ref())
516 .map(|rev| (RevisionId(*id), rev))
517 })
518 }
519
520 pub fn revisions_by<'a>(
522 &'a self,
523 author: &'a PublicKey,
524 ) -> impl DoubleEndedIterator<Item = (RevisionId, &Revision)> {
525 self.revisions()
526 .filter(move |(_, r)| (r.author.public_key() == author))
527 }
528
529 pub fn reviews_of(&self, rev: RevisionId) -> impl Iterator<Item = (&ReviewId, &Review)> {
531 self.reviews.iter().filter_map(move |(review_id, t)| {
532 t.and_then(|(rev_id, pk)| {
533 if rev == rev_id {
534 self.revision(&rev_id)
535 .and_then(|r| r.review_by(&pk))
536 .map(|r| (review_id, r))
537 } else {
538 None
539 }
540 })
541 })
542 }
543
544 pub fn assignees(&self) -> impl Iterator<Item = Did> + '_ {
546 self.assignees.iter().map(Did::from)
547 }
548
549 pub fn merges(&self) -> impl Iterator<Item = (&ActorId, &Merge)> {
551 self.merges.iter()
552 }
553
554 pub fn head(&self) -> &git::Oid {
556 &self.latest().1.oid
557 }
558
559 pub fn base(&self) -> &git::Oid {
562 &self.latest().1.base
563 }
564
565 pub fn merge_base<R: ReadRepository>(&self, repo: &R) -> Result<git::Oid, git::ext::Error> {
567 repo.merge_base(self.base(), self.head())
568 }
569
570 pub fn range(&self) -> Result<(git::Oid, git::Oid), git::ext::Error> {
572 return Ok((*self.base(), *self.head()));
573 }
574
575 pub fn version(&self) -> RevisionIx {
577 self.revisions
578 .len()
579 .checked_sub(1)
580 .expect("Patch::version: at least one revision is present")
581 }
582
583 pub fn root(&self) -> (RevisionId, &Revision) {
587 self.updates()
588 .next()
589 .expect("Patch::root: there is always a root revision")
590 }
591
592 pub fn latest(&self) -> (RevisionId, &Revision) {
594 self.latest_by(self.author().public_key())
595 .expect("Patch::latest: there is always at least one revision")
596 }
597
598 pub fn latest_by<'a>(&'a self, author: &'a PublicKey) -> Option<(RevisionId, &Revision)> {
600 self.revisions_by(author).next_back()
601 }
602
603 pub fn updated_at(&self) -> Timestamp {
605 self.latest().1.timestamp()
606 }
607
608 pub fn is_merged(&self) -> bool {
610 matches!(self.state(), State::Merged { .. })
611 }
612
613 pub fn is_open(&self) -> bool {
615 matches!(self.state(), State::Open { .. })
616 }
617
618 pub fn is_archived(&self) -> bool {
620 matches!(self.state(), State::Archived)
621 }
622
623 pub fn is_draft(&self) -> bool {
625 matches!(self.state(), State::Draft)
626 }
627
628 pub fn authorization(
630 &self,
631 action: &Action,
632 actor: &ActorId,
633 doc: &Doc,
634 ) -> Result<Authorization, Error> {
635 if doc.is_delegate(&actor.into()) {
636 return Ok(Authorization::Allow);
638 }
639 let author = self.author().id().as_key();
640 let outcome = match action {
641 Action::Edit { .. } => Authorization::from(actor == author),
643 Action::Lifecycle { state } => Authorization::from(match state {
644 Lifecycle::Open { .. } => actor == author,
645 Lifecycle::Draft { .. } => actor == author,
646 Lifecycle::Archived { .. } => actor == author,
647 }),
648 Action::Label { labels } => {
650 if labels == &self.labels {
651 Authorization::Allow
653 } else {
654 Authorization::Deny
655 }
656 }
657 Action::Assign { .. } => Authorization::Deny,
658 Action::Merge { .. } => match self.target() {
659 MergeTarget::Delegates => Authorization::Deny,
660 },
661 Action::Review { .. } => Authorization::Allow,
663 Action::ReviewRedact { review, .. } | Action::ReviewEdit { review, .. } => {
664 if let Some((_, review)) = lookup::review(self, review)? {
665 Authorization::from(actor == review.author.public_key())
666 } else {
667 Authorization::Unknown
669 }
670 }
671 Action::ReviewComment { .. } => Authorization::Allow,
673 Action::ReviewCommentEdit {
675 review, comment, ..
676 }
677 | Action::ReviewCommentRedact { review, comment } => {
678 if let Some((_, review)) = lookup::review(self, review)? {
679 if let Some(comment) = review.comments.comment(comment) {
680 return Ok(Authorization::from(*actor == comment.author()));
681 }
682 }
683 Authorization::Unknown
685 }
686 Action::ReviewCommentReact { .. } => Authorization::Allow,
688 Action::ReviewCommentResolve { review, comment }
690 | Action::ReviewCommentUnresolve { review, comment } => {
691 if let Some((revision, review)) = lookup::review(self, review)? {
692 if let Some(comment) = review.comments.comment(comment) {
693 return Ok(Authorization::from(
694 actor == &comment.author()
695 || actor == review.author.public_key()
696 || actor == revision.author.public_key(),
697 ));
698 }
699 }
700 Authorization::Unknown
702 }
703 Action::Revision { .. } => Authorization::Allow,
705 Action::RevisionEdit { revision, .. } | Action::RevisionRedact { revision, .. } => {
707 if let Some(revision) = lookup::revision(self, revision)? {
708 Authorization::from(actor == revision.author.public_key())
709 } else {
710 Authorization::Unknown
712 }
713 }
714 Action::RevisionReact { .. } => Authorization::Allow,
716 Action::RevisionComment { .. } => Authorization::Allow,
717 Action::RevisionCommentEdit {
719 revision, comment, ..
720 }
721 | Action::RevisionCommentRedact {
722 revision, comment, ..
723 } => {
724 if let Some(revision) = lookup::revision(self, revision)? {
725 if let Some(comment) = revision.discussion.comment(comment) {
726 return Ok(Authorization::from(actor == &comment.author()));
727 }
728 }
729 Authorization::Unknown
731 }
732 Action::RevisionCommentReact { .. } => Authorization::Allow,
734 };
735 Ok(outcome)
736 }
737}
738
739impl Patch {
740 fn op_action<R: ReadRepository>(
742 &mut self,
743 action: Action,
744 id: EntryId,
745 author: ActorId,
746 timestamp: Timestamp,
747 concurrent: &[&cob::Entry],
748 doc: &DocAt,
749 repo: &R,
750 ) -> Result<(), Error> {
751 match self.authorization(&action, &author, doc)? {
752 Authorization::Allow => {
753 self.action(action, id, author, timestamp, concurrent, doc, repo)
754 }
755 Authorization::Deny => Err(Error::NotAuthorized(author, action)),
756 Authorization::Unknown => {
757 Ok(())
762 }
763 }
764 }
765
766 fn action<R: ReadRepository>(
768 &mut self,
769 action: Action,
770 entry: EntryId,
771 author: ActorId,
772 timestamp: Timestamp,
773 _concurrent: &[&cob::Entry],
774 identity: &Doc,
775 repo: &R,
776 ) -> Result<(), Error> {
777 match action {
778 Action::Edit { title, target } => {
779 self.title = title;
780 self.target = target;
781 }
782 Action::Lifecycle { state } => {
783 let valid = self.state == State::Draft
784 || self.state == State::Archived
785 || self.state == State::Open { conflicts: vec![] };
786
787 if valid {
788 match state {
789 Lifecycle::Open => {
790 self.state = State::Open { conflicts: vec![] };
791 }
792 Lifecycle::Draft => {
793 self.state = State::Draft;
794 }
795 Lifecycle::Archived => {
796 self.state = State::Archived;
797 }
798 }
799 }
800 }
801 Action::Label { labels } => {
802 self.labels = BTreeSet::from_iter(labels);
803 }
804 Action::Assign { assignees } => {
805 self.assignees = BTreeSet::from_iter(assignees.into_iter().map(ActorId::from));
806 }
807 Action::RevisionEdit {
808 revision,
809 description,
810 embeds,
811 } => {
812 if let Some(redactable) = self.revisions.get_mut(&revision) {
813 if let Some(revision) = redactable {
815 revision.description.push(Edit::new(
816 author,
817 description,
818 timestamp,
819 embeds,
820 ));
821 }
822 } else {
823 return Err(Error::Missing(revision.into_inner()));
824 }
825 }
826 Action::Revision {
827 description,
828 base,
829 oid,
830 resolves,
831 } => {
832 debug_assert!(!self.revisions.contains_key(&entry));
833 let id = RevisionId(entry);
834
835 self.revisions.insert(
836 id,
837 Some(Revision::new(
838 id,
839 author.into(),
840 description,
841 base,
842 oid,
843 timestamp,
844 resolves,
845 )),
846 );
847 }
848 Action::RevisionReact {
849 revision,
850 reaction,
851 active,
852 location,
853 } => {
854 if let Some(revision) = lookup::revision_mut(self, &revision)? {
855 let key = (author, reaction);
856 let reactions = revision.reactions.entry(location).or_default();
857
858 if active {
859 reactions.insert(key);
860 } else {
861 reactions.remove(&key);
862 }
863 }
864 }
865 Action::RevisionRedact { revision } => {
866 let (root, _) = self.root();
868 if revision == root {
869 return Err(Error::NotAllowed(entry));
870 }
871 if let Some(r) = self.revisions.get_mut(&revision) {
873 if self.merges.values().any(|m| m.revision == revision) {
876 return Ok(());
877 }
878 *r = None;
879 } else {
880 return Err(Error::Missing(revision.into_inner()));
881 }
882 }
883 Action::Review {
884 revision,
885 ref summary,
886 verdict,
887 labels,
888 } => {
889 let Some(rev) = self.revisions.get_mut(&revision) else {
890 return Ok(());
892 };
893 if let Some(rev) = rev {
894 if let btree_map::Entry::Vacant(e) = rev.reviews.entry(author) {
897 let id = ReviewId(entry);
898
899 e.insert(Review::new(
900 id,
901 Author::new(author),
902 verdict,
903 summary.to_owned(),
904 labels,
905 timestamp,
906 ));
907 self.reviews.insert(id, Some((revision, author)));
909 } else {
910 log::error!(
911 target: "patch",
912 "Review by {author} for {revision} already exists, ignoring action.."
913 );
914 }
915 }
916 }
917 Action::ReviewEdit {
918 review,
919 summary,
920 verdict,
921 labels,
922 } => {
923 if summary.is_none() && verdict.is_none() {
924 return Err(Error::EmptyReview);
925 }
926 let Some(review) = lookup::review_mut(self, &review)? else {
927 return Ok(());
928 };
929 review.verdict = verdict;
930 review.summary = summary;
931 review.labels = labels;
932 }
933 Action::ReviewCommentReact {
934 review,
935 comment,
936 reaction,
937 active,
938 } => {
939 if let Some(review) = lookup::review_mut(self, &review)? {
940 thread::react(
941 &mut review.comments,
942 entry,
943 author,
944 comment,
945 reaction,
946 active,
947 )?;
948 }
949 }
950 Action::ReviewCommentRedact { review, comment } => {
951 if let Some(review) = lookup::review_mut(self, &review)? {
952 thread::redact(&mut review.comments, entry, comment)?;
953 }
954 }
955 Action::ReviewCommentEdit {
956 review,
957 comment,
958 body,
959 embeds,
960 } => {
961 if let Some(review) = lookup::review_mut(self, &review)? {
962 thread::edit(
963 &mut review.comments,
964 entry,
965 author,
966 comment,
967 timestamp,
968 body,
969 embeds,
970 )?;
971 }
972 }
973 Action::ReviewCommentResolve { review, comment } => {
974 if let Some(review) = lookup::review_mut(self, &review)? {
975 thread::resolve(&mut review.comments, entry, comment)?;
976 }
977 }
978 Action::ReviewCommentUnresolve { review, comment } => {
979 if let Some(review) = lookup::review_mut(self, &review)? {
980 thread::unresolve(&mut review.comments, entry, comment)?;
981 }
982 }
983 Action::ReviewComment {
984 review,
985 body,
986 location,
987 reply_to,
988 embeds,
989 } => {
990 if let Some(review) = lookup::review_mut(self, &review)? {
991 thread::comment(
992 &mut review.comments,
993 entry,
994 author,
995 timestamp,
996 body,
997 reply_to,
998 location,
999 embeds,
1000 )?;
1001 }
1002 }
1003 Action::ReviewRedact { review } => {
1004 let Some(locator) = self.reviews.get_mut(&review) else {
1006 return Err(Error::Missing(review.into_inner()));
1007 };
1008 let Some((revision, reviewer)) = locator else {
1010 return Ok(());
1011 };
1012 let Some(redactable) = self.revisions.get_mut(revision) else {
1014 return Err(Error::Missing(revision.into_inner()));
1015 };
1016 let Some(revision) = redactable else {
1018 return Ok(());
1019 };
1020 if let Some(r) = revision.reviews.remove(reviewer) {
1022 debug_assert_eq!(r.id, review);
1023 } else {
1024 log::error!(
1025 target: "patch", "Review {review} not found in revision {}", revision.id
1026 );
1027 }
1028 *locator = None;
1030 }
1031 Action::Merge { revision, commit } => {
1032 if lookup::revision_mut(self, &revision)?.is_none() {
1034 return Ok(());
1035 };
1036 match self.target() {
1037 MergeTarget::Delegates => {
1038 let proj = identity.project()?;
1039 let branch = git::refs::branch(proj.default_branch());
1040
1041 let Ok(head) = repo.reference_oid(&author, &branch) else {
1048 return Ok(());
1049 };
1050 if commit != head && !repo.is_ancestor_of(commit, head)? {
1051 return Ok(());
1052 }
1053 }
1054 }
1055 self.merges.insert(
1056 author,
1057 Merge {
1058 revision,
1059 commit,
1060 timestamp,
1061 },
1062 );
1063
1064 let mut merges = self.merges.iter().fold(
1065 HashMap::<(RevisionId, git::Oid), usize>::new(),
1066 |mut acc, (_, merge)| {
1067 *acc.entry((merge.revision, merge.commit)).or_default() += 1;
1068 acc
1069 },
1070 );
1071 merges.retain(|_, count| *count >= identity.threshold());
1073
1074 match merges.into_keys().collect::<Vec<_>>().as_slice() {
1075 [] => {
1076 }
1078 [(revision, commit)] => {
1079 self.state = State::Merged {
1081 revision: *revision,
1082 commit: *commit,
1083 };
1084 }
1085 revisions => {
1086 self.state = State::Open {
1088 conflicts: revisions.to_vec(),
1089 };
1090 }
1091 }
1092 }
1093
1094 Action::RevisionComment {
1095 revision,
1096 body,
1097 reply_to,
1098 embeds,
1099 location,
1100 } => {
1101 if let Some(revision) = lookup::revision_mut(self, &revision)? {
1102 thread::comment(
1103 &mut revision.discussion,
1104 entry,
1105 author,
1106 timestamp,
1107 body,
1108 reply_to,
1109 location,
1110 embeds,
1111 )?;
1112 }
1113 }
1114 Action::RevisionCommentEdit {
1115 revision,
1116 comment,
1117 body,
1118 embeds,
1119 } => {
1120 if let Some(revision) = lookup::revision_mut(self, &revision)? {
1121 thread::edit(
1122 &mut revision.discussion,
1123 entry,
1124 author,
1125 comment,
1126 timestamp,
1127 body,
1128 embeds,
1129 )?;
1130 }
1131 }
1132 Action::RevisionCommentRedact { revision, comment } => {
1133 if let Some(revision) = lookup::revision_mut(self, &revision)? {
1134 thread::redact(&mut revision.discussion, entry, comment)?;
1135 }
1136 }
1137 Action::RevisionCommentReact {
1138 revision,
1139 comment,
1140 reaction,
1141 active,
1142 } => {
1143 if let Some(revision) = lookup::revision_mut(self, &revision)? {
1144 thread::react(
1145 &mut revision.discussion,
1146 entry,
1147 author,
1148 comment,
1149 reaction,
1150 active,
1151 )?;
1152 }
1153 }
1154 }
1155 Ok(())
1156 }
1157}
1158
1159impl store::Cob for Patch {
1160 type Action = Action;
1161 type Error = Error;
1162
1163 fn type_name() -> &'static TypeName {
1164 &TYPENAME
1165 }
1166
1167 fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
1168 let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
1169 let mut actions = op.actions.into_iter();
1170 let Some(Action::Revision {
1171 description,
1172 base,
1173 oid,
1174 resolves,
1175 }) = actions.next()
1176 else {
1177 return Err(Error::Init("the first action must be of type `revision`"));
1178 };
1179 let Some(Action::Edit { title, target }) = actions.next() else {
1180 return Err(Error::Init("the second action must be of type `edit`"));
1181 };
1182 let revision = Revision::new(
1183 RevisionId(op.id),
1184 op.author.into(),
1185 description,
1186 base,
1187 oid,
1188 op.timestamp,
1189 resolves,
1190 );
1191 let mut patch = Patch::new(title, target, (RevisionId(op.id), revision));
1192
1193 for action in actions {
1194 match patch.authorization(&action, &op.author, &doc)? {
1195 Authorization::Allow => {
1196 patch.action(action, op.id, op.author, op.timestamp, &[], &doc, repo)?;
1197 }
1198 Authorization::Deny => {
1199 return Err(Error::NotAuthorized(op.author, action));
1200 }
1201 Authorization::Unknown => {
1202 continue;
1205 }
1206 }
1207 }
1208 Ok(patch)
1209 }
1210
1211 fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a cob::Entry>>(
1212 &mut self,
1213 op: Op,
1214 concurrent: I,
1215 repo: &R,
1216 ) -> Result<(), Error> {
1217 debug_assert!(!self.timeline.contains(&op.id));
1218 self.timeline.push(op.id);
1219
1220 let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
1221 let concurrent = concurrent.into_iter().collect::<Vec<_>>();
1222
1223 for action in op.actions {
1224 log::trace!(target: "patch", "Applying {} {action:?}", op.id);
1225
1226 if let Err(e) = self.op_action(
1227 action,
1228 op.id,
1229 op.author,
1230 op.timestamp,
1231 &concurrent,
1232 &doc,
1233 repo,
1234 ) {
1235 log::error!(target: "patch", "Error applying {}: {e}", op.id);
1236 return Err(e);
1237 }
1238 }
1239 Ok(())
1240 }
1241}
1242
1243impl<R: ReadRepository> cob::Evaluate<R> for Patch {
1244 type Error = Error;
1245
1246 fn init(entry: &cob::Entry, repo: &R) -> Result<Self, Self::Error> {
1247 let op = Op::try_from(entry)?;
1248 let object = Patch::from_root(op, repo)?;
1249
1250 Ok(object)
1251 }
1252
1253 fn apply<'a, I: Iterator<Item = (&'a EntryId, &'a cob::Entry)>>(
1254 &mut self,
1255 entry: &cob::Entry,
1256 concurrent: I,
1257 repo: &R,
1258 ) -> Result<(), Self::Error> {
1259 let op = Op::try_from(entry)?;
1260
1261 self.op(op, concurrent.map(|(_, e)| e), repo)
1262 }
1263}
1264
1265mod lookup {
1266 use super::*;
1267
1268 pub fn revision<'a>(
1269 patch: &'a Patch,
1270 revision: &RevisionId,
1271 ) -> Result<Option<&'a Revision>, Error> {
1272 match patch.revisions.get(revision) {
1273 Some(Some(revision)) => Ok(Some(revision)),
1274 Some(None) => Ok(None),
1276 None => Err(Error::Missing(revision.into_inner())),
1278 }
1279 }
1280
1281 pub fn revision_mut<'a>(
1282 patch: &'a mut Patch,
1283 revision: &RevisionId,
1284 ) -> Result<Option<&'a mut Revision>, Error> {
1285 match patch.revisions.get_mut(revision) {
1286 Some(Some(revision)) => Ok(Some(revision)),
1287 Some(None) => Ok(None),
1289 None => Err(Error::Missing(revision.into_inner())),
1291 }
1292 }
1293
1294 pub fn review<'a>(
1295 patch: &'a Patch,
1296 review: &ReviewId,
1297 ) -> Result<Option<(&'a Revision, &'a Review)>, Error> {
1298 match patch.reviews.get(review) {
1299 Some(Some((revision, author))) => {
1300 match patch.revisions.get(revision) {
1301 Some(Some(rev)) => {
1302 let r = rev
1303 .reviews
1304 .get(author)
1305 .ok_or_else(|| Error::Missing(review.into_inner()))?;
1306 debug_assert_eq!(&r.id, review);
1307
1308 Ok(Some((rev, r)))
1309 }
1310 Some(None) => {
1311 Ok(None)
1314 }
1315 None => Err(Error::Missing(revision.into_inner())),
1316 }
1317 }
1318 Some(None) => {
1319 Ok(None)
1321 }
1322 None => Err(Error::Missing(review.into_inner())),
1323 }
1324 }
1325
1326 pub fn review_mut<'a>(
1327 patch: &'a mut Patch,
1328 review: &ReviewId,
1329 ) -> Result<Option<&'a mut Review>, Error> {
1330 match patch.reviews.get(review) {
1331 Some(Some((revision, author))) => {
1332 match patch.revisions.get_mut(revision) {
1333 Some(Some(rev)) => {
1334 let r = rev
1335 .reviews
1336 .get_mut(author)
1337 .ok_or_else(|| Error::Missing(review.into_inner()))?;
1338 debug_assert_eq!(&r.id, review);
1339
1340 Ok(Some(r))
1341 }
1342 Some(None) => {
1343 Ok(None)
1346 }
1347 None => Err(Error::Missing(revision.into_inner())),
1348 }
1349 }
1350 Some(None) => {
1351 Ok(None)
1353 }
1354 None => Err(Error::Missing(review.into_inner())),
1355 }
1356 }
1357}
1358
1359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1361#[serde(rename_all = "camelCase")]
1362pub struct Revision {
1363 pub(super) id: RevisionId,
1365 pub(super) author: Author,
1367 pub(super) description: NonEmpty<Edit>,
1369 pub(super) base: git::Oid,
1371 pub(super) oid: git::Oid,
1373 pub(super) discussion: Thread<Comment<CodeLocation>>,
1375 pub(super) reviews: BTreeMap<ActorId, Review>,
1377 pub(super) timestamp: Timestamp,
1379 pub(super) resolves: BTreeSet<(EntryId, CommentId)>,
1381 #[serde(
1383 serialize_with = "ser::serialize_reactions",
1384 deserialize_with = "ser::deserialize_reactions"
1385 )]
1386 pub(super) reactions: BTreeMap<Option<CodeLocation>, Reactions>,
1387}
1388
1389impl Revision {
1390 pub fn new(
1391 id: RevisionId,
1392 author: Author,
1393 description: String,
1394 base: git::Oid,
1395 oid: git::Oid,
1396 timestamp: Timestamp,
1397 resolves: BTreeSet<(EntryId, CommentId)>,
1398 ) -> Self {
1399 let description = Edit::new(*author.public_key(), description, timestamp, Vec::default());
1400
1401 Self {
1402 id,
1403 author,
1404 description: NonEmpty::new(description),
1405 base,
1406 oid,
1407 discussion: Thread::default(),
1408 reviews: BTreeMap::default(),
1409 timestamp,
1410 resolves,
1411 reactions: Default::default(),
1412 }
1413 }
1414
1415 pub fn id(&self) -> RevisionId {
1416 self.id
1417 }
1418
1419 pub fn description(&self) -> &str {
1420 self.description.last().body.as_str()
1421 }
1422
1423 pub fn edits(&self) -> impl Iterator<Item = &Edit> {
1424 self.description.iter()
1425 }
1426
1427 pub fn embeds(&self) -> &[Embed<Uri>] {
1428 &self.description.last().embeds
1429 }
1430
1431 pub fn reactions(&self) -> &BTreeMap<Option<CodeLocation>, BTreeSet<(PublicKey, Reaction)>> {
1432 &self.reactions
1433 }
1434
1435 pub fn author(&self) -> &Author {
1437 &self.author
1438 }
1439
1440 pub fn base(&self) -> &git::Oid {
1442 &self.base
1443 }
1444
1445 pub fn head(&self) -> git::Oid {
1447 self.oid
1448 }
1449
1450 pub fn range(&self) -> (git::Oid, git::Oid) {
1452 (self.base, self.oid)
1453 }
1454
1455 pub fn timestamp(&self) -> Timestamp {
1457 self.timestamp
1458 }
1459
1460 pub fn discussion(&self) -> &Thread<Comment<CodeLocation>> {
1462 &self.discussion
1463 }
1464
1465 pub fn replies(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment<CodeLocation>)> {
1467 self.discussion.comments()
1468 }
1469
1470 pub fn reviews(&self) -> impl DoubleEndedIterator<Item = (&PublicKey, &Review)> {
1472 self.reviews.iter()
1473 }
1474
1475 pub fn review_by(&self, author: &ActorId) -> Option<&Review> {
1477 self.reviews.get(author)
1478 }
1479}
1480
1481#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1483#[serde(rename_all = "camelCase", tag = "status")]
1484pub enum State {
1485 Draft,
1486 Open {
1487 #[serde(skip_serializing_if = "Vec::is_empty")]
1489 #[serde(default)]
1490 conflicts: Vec<(RevisionId, git::Oid)>,
1491 },
1492 Archived,
1493 Merged {
1494 revision: RevisionId,
1496 commit: git::Oid,
1498 },
1499}
1500
1501impl Default for State {
1502 fn default() -> Self {
1503 Self::Open { conflicts: vec![] }
1504 }
1505}
1506
1507impl fmt::Display for State {
1508 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1509 match self {
1510 Self::Archived => write!(f, "archived"),
1511 Self::Draft => write!(f, "draft"),
1512 Self::Open { .. } => write!(f, "open"),
1513 Self::Merged { .. } => write!(f, "merged"),
1514 }
1515 }
1516}
1517
1518impl From<&State> for Status {
1519 fn from(value: &State) -> Self {
1520 match value {
1521 State::Draft => Self::Draft,
1522 State::Open { .. } => Self::Open,
1523 State::Archived => Self::Archived,
1524 State::Merged { .. } => Self::Merged,
1525 }
1526 }
1527}
1528
1529#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1532pub enum Status {
1533 Draft,
1534 #[default]
1535 Open,
1536 Archived,
1537 Merged,
1538}
1539
1540impl fmt::Display for Status {
1541 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1542 match self {
1543 Self::Archived => write!(f, "archived"),
1544 Self::Draft => write!(f, "draft"),
1545 Self::Open => write!(f, "open"),
1546 Self::Merged => write!(f, "merged"),
1547 }
1548 }
1549}
1550
1551#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1553#[serde(rename_all = "camelCase", tag = "status")]
1554pub enum Lifecycle {
1555 #[default]
1556 Open,
1557 Draft,
1558 Archived,
1559}
1560
1561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
1563#[serde(rename_all = "camelCase")]
1564pub struct Merge {
1565 pub revision: RevisionId,
1567 pub commit: git::Oid,
1569 pub timestamp: Timestamp,
1571}
1572
1573#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1575#[serde(rename_all = "camelCase")]
1576pub enum Verdict {
1577 Accept,
1579 Reject,
1581}
1582
1583impl fmt::Display for Verdict {
1584 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1585 match self {
1586 Self::Accept => write!(f, "accept"),
1587 Self::Reject => write!(f, "reject"),
1588 }
1589 }
1590}
1591
1592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1594#[serde(rename_all = "camelCase")]
1595pub struct Review {
1596 pub(super) id: ReviewId,
1598 pub(super) author: Author,
1600 pub(super) verdict: Option<Verdict>,
1604 pub(super) summary: Option<String>,
1608 pub(super) comments: Thread<Comment<CodeLocation>>,
1610 pub(super) labels: Vec<Label>,
1613 pub(super) timestamp: Timestamp,
1615}
1616
1617impl Review {
1618 pub fn new(
1619 id: ReviewId,
1620 author: Author,
1621 verdict: Option<Verdict>,
1622 summary: Option<String>,
1623 labels: Vec<Label>,
1624 timestamp: Timestamp,
1625 ) -> Self {
1626 Self {
1627 id,
1628 author,
1629 verdict,
1630 summary,
1631 comments: Thread::default(),
1632 labels,
1633 timestamp,
1634 }
1635 }
1636
1637 pub fn id(&self) -> ReviewId {
1639 self.id
1640 }
1641
1642 pub fn author(&self) -> &Author {
1644 &self.author
1645 }
1646
1647 pub fn verdict(&self) -> Option<Verdict> {
1649 self.verdict
1650 }
1651
1652 pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&EntryId, &Comment<CodeLocation>)> {
1654 self.comments.comments()
1655 }
1656
1657 pub fn summary(&self) -> Option<&str> {
1659 self.summary.as_deref()
1660 }
1661
1662 pub fn timestamp(&self) -> Timestamp {
1664 self.timestamp
1665 }
1666}
1667
1668impl<R: ReadRepository> store::Transaction<Patch, R> {
1669 pub fn edit(&mut self, title: impl ToString, target: MergeTarget) -> Result<(), store::Error> {
1670 self.push(Action::Edit {
1671 title: title.to_string(),
1672 target,
1673 })
1674 }
1675
1676 pub fn edit_revision(
1677 &mut self,
1678 revision: RevisionId,
1679 description: impl ToString,
1680 embeds: Vec<Embed<Uri>>,
1681 ) -> Result<(), store::Error> {
1682 self.embed(embeds.clone())?;
1683 self.push(Action::RevisionEdit {
1684 revision,
1685 description: description.to_string(),
1686 embeds,
1687 })
1688 }
1689
1690 pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> {
1692 self.push(Action::RevisionRedact { revision })
1693 }
1694
1695 pub fn thread<S: ToString>(
1697 &mut self,
1698 revision: RevisionId,
1699 body: S,
1700 ) -> Result<(), store::Error> {
1701 self.push(Action::RevisionComment {
1702 revision,
1703 body: body.to_string(),
1704 reply_to: None,
1705 location: None,
1706 embeds: vec![],
1707 })
1708 }
1709
1710 pub fn react(
1712 &mut self,
1713 revision: RevisionId,
1714 reaction: Reaction,
1715 location: Option<CodeLocation>,
1716 active: bool,
1717 ) -> Result<(), store::Error> {
1718 self.push(Action::RevisionReact {
1719 revision,
1720 reaction,
1721 location,
1722 active,
1723 })
1724 }
1725
1726 pub fn comment<S: ToString>(
1728 &mut self,
1729 revision: RevisionId,
1730 body: S,
1731 reply_to: Option<CommentId>,
1732 location: Option<CodeLocation>,
1733 embeds: Vec<Embed<Uri>>,
1734 ) -> Result<(), store::Error> {
1735 self.embed(embeds.clone())?;
1736 self.push(Action::RevisionComment {
1737 revision,
1738 body: body.to_string(),
1739 reply_to,
1740 location,
1741 embeds,
1742 })
1743 }
1744
1745 pub fn comment_edit<S: ToString>(
1747 &mut self,
1748 revision: RevisionId,
1749 comment: CommentId,
1750 body: S,
1751 embeds: Vec<Embed<Uri>>,
1752 ) -> Result<(), store::Error> {
1753 self.embed(embeds.clone())?;
1754 self.push(Action::RevisionCommentEdit {
1755 revision,
1756 comment,
1757 body: body.to_string(),
1758 embeds,
1759 })
1760 }
1761
1762 pub fn comment_react(
1764 &mut self,
1765 revision: RevisionId,
1766 comment: CommentId,
1767 reaction: Reaction,
1768 active: bool,
1769 ) -> Result<(), store::Error> {
1770 self.push(Action::RevisionCommentReact {
1771 revision,
1772 comment,
1773 reaction,
1774 active,
1775 })
1776 }
1777
1778 pub fn comment_redact(
1780 &mut self,
1781 revision: RevisionId,
1782 comment: CommentId,
1783 ) -> Result<(), store::Error> {
1784 self.push(Action::RevisionCommentRedact { revision, comment })
1785 }
1786
1787 pub fn review_comment<S: ToString>(
1789 &mut self,
1790 review: ReviewId,
1791 body: S,
1792 location: Option<CodeLocation>,
1793 reply_to: Option<CommentId>,
1794 embeds: Vec<Embed<Uri>>,
1795 ) -> Result<(), store::Error> {
1796 self.embed(embeds.clone())?;
1797 self.push(Action::ReviewComment {
1798 review,
1799 body: body.to_string(),
1800 location,
1801 reply_to,
1802 embeds,
1803 })
1804 }
1805
1806 pub fn review_comment_resolve(
1808 &mut self,
1809 review: ReviewId,
1810 comment: CommentId,
1811 ) -> Result<(), store::Error> {
1812 self.push(Action::ReviewCommentResolve { review, comment })
1813 }
1814
1815 pub fn review_comment_unresolve(
1817 &mut self,
1818 review: ReviewId,
1819 comment: CommentId,
1820 ) -> Result<(), store::Error> {
1821 self.push(Action::ReviewCommentUnresolve { review, comment })
1822 }
1823
1824 pub fn edit_review_comment<S: ToString>(
1826 &mut self,
1827 review: ReviewId,
1828 comment: EntryId,
1829 body: S,
1830 embeds: Vec<Embed<Uri>>,
1831 ) -> Result<(), store::Error> {
1832 self.embed(embeds.clone())?;
1833 self.push(Action::ReviewCommentEdit {
1834 review,
1835 comment,
1836 body: body.to_string(),
1837 embeds,
1838 })
1839 }
1840
1841 pub fn react_review_comment(
1843 &mut self,
1844 review: ReviewId,
1845 comment: EntryId,
1846 reaction: Reaction,
1847 active: bool,
1848 ) -> Result<(), store::Error> {
1849 self.push(Action::ReviewCommentReact {
1850 review,
1851 comment,
1852 reaction,
1853 active,
1854 })
1855 }
1856
1857 pub fn redact_review_comment(
1859 &mut self,
1860 review: ReviewId,
1861 comment: EntryId,
1862 ) -> Result<(), store::Error> {
1863 self.push(Action::ReviewCommentRedact { review, comment })
1864 }
1865
1866 pub fn review(
1869 &mut self,
1870 revision: RevisionId,
1871 verdict: Option<Verdict>,
1872 summary: Option<String>,
1873 labels: Vec<Label>,
1874 ) -> Result<(), store::Error> {
1875 self.push(Action::Review {
1876 revision,
1877 summary,
1878 verdict,
1879 labels,
1880 })
1881 }
1882
1883 pub fn review_edit(
1885 &mut self,
1886 review: ReviewId,
1887 verdict: Option<Verdict>,
1888 summary: Option<String>,
1889 labels: Vec<Label>,
1890 ) -> Result<(), store::Error> {
1891 self.push(Action::ReviewEdit {
1892 review,
1893 summary,
1894 verdict,
1895 labels,
1896 })
1897 }
1898
1899 pub fn redact_review(&mut self, review: ReviewId) -> Result<(), store::Error> {
1901 self.push(Action::ReviewRedact { review })
1902 }
1903
1904 pub fn merge(&mut self, revision: RevisionId, commit: git::Oid) -> Result<(), store::Error> {
1906 self.push(Action::Merge { revision, commit })
1907 }
1908
1909 pub fn revision(
1911 &mut self,
1912 description: impl ToString,
1913 base: impl Into<git::Oid>,
1914 oid: impl Into<git::Oid>,
1915 ) -> Result<(), store::Error> {
1916 self.push(Action::Revision {
1917 description: description.to_string(),
1918 base: base.into(),
1919 oid: oid.into(),
1920 resolves: BTreeSet::new(),
1921 })
1922 }
1923
1924 pub fn lifecycle(&mut self, state: Lifecycle) -> Result<(), store::Error> {
1926 self.push(Action::Lifecycle { state })
1927 }
1928
1929 pub fn assign(&mut self, assignees: BTreeSet<Did>) -> Result<(), store::Error> {
1931 self.push(Action::Assign { assignees })
1932 }
1933
1934 pub fn label(&mut self, labels: impl IntoIterator<Item = Label>) -> Result<(), store::Error> {
1936 self.push(Action::Label {
1937 labels: labels.into_iter().collect(),
1938 })
1939 }
1940}
1941
1942pub struct PatchMut<'a, 'g, R, C> {
1943 pub id: ObjectId,
1944
1945 patch: Patch,
1946 store: &'g mut Patches<'a, R>,
1947 cache: &'g mut C,
1948}
1949
1950impl<'a, 'g, R, C> PatchMut<'a, 'g, R, C>
1951where
1952 C: cob::cache::Update<Patch>,
1953 R: ReadRepository + SignRepository + cob::Store,
1954{
1955 pub fn new(id: ObjectId, patch: Patch, cache: &'g mut Cache<Patches<'a, R>, C>) -> Self {
1956 Self {
1957 id,
1958 patch,
1959 store: &mut cache.store,
1960 cache: &mut cache.cache,
1961 }
1962 }
1963
1964 pub fn id(&self) -> &ObjectId {
1965 &self.id
1966 }
1967
1968 pub fn reload(&mut self) -> Result<(), store::Error> {
1970 self.patch = self
1971 .store
1972 .get(&self.id)?
1973 .ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?;
1974
1975 Ok(())
1976 }
1977
1978 pub fn transaction<G, F>(
1979 &mut self,
1980 message: &str,
1981 signer: &G,
1982 operations: F,
1983 ) -> Result<EntryId, Error>
1984 where
1985 G: Signer,
1986 F: FnOnce(&mut Transaction<Patch, R>) -> Result<(), store::Error>,
1987 {
1988 let mut tx = Transaction::default();
1989 operations(&mut tx)?;
1990
1991 let (patch, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
1992 self.cache
1993 .update(&self.store.as_ref().id(), &self.id, &patch)
1994 .map_err(|e| Error::CacheUpdate {
1995 id: self.id,
1996 err: e.into(),
1997 })?;
1998 self.patch = patch;
1999
2000 Ok(commit)
2001 }
2002
2003 pub fn edit<G: Signer>(
2005 &mut self,
2006 title: String,
2007 target: MergeTarget,
2008 signer: &G,
2009 ) -> Result<EntryId, Error> {
2010 self.transaction("Edit", signer, |tx| tx.edit(title, target))
2011 }
2012
2013 pub fn edit_revision<G: Signer>(
2015 &mut self,
2016 revision: RevisionId,
2017 description: String,
2018 embeds: impl IntoIterator<Item = Embed<Uri>>,
2019 signer: &G,
2020 ) -> Result<EntryId, Error> {
2021 self.transaction("Edit revision", signer, |tx| {
2022 tx.edit_revision(revision, description, embeds.into_iter().collect())
2023 })
2024 }
2025
2026 pub fn redact<G: Signer>(
2028 &mut self,
2029 revision: RevisionId,
2030 signer: &G,
2031 ) -> Result<EntryId, Error> {
2032 self.transaction("Redact revision", signer, |tx| tx.redact(revision))
2033 }
2034
2035 pub fn thread<G: Signer, S: ToString>(
2037 &mut self,
2038 revision: RevisionId,
2039 body: S,
2040 signer: &G,
2041 ) -> Result<CommentId, Error> {
2042 self.transaction("Create thread", signer, |tx| tx.thread(revision, body))
2043 }
2044
2045 pub fn comment<G: Signer, S: ToString>(
2047 &mut self,
2048 revision: RevisionId,
2049 body: S,
2050 reply_to: Option<CommentId>,
2051 location: Option<CodeLocation>,
2052 embeds: impl IntoIterator<Item = Embed<Uri>>,
2053 signer: &G,
2054 ) -> Result<EntryId, Error> {
2055 self.transaction("Comment", signer, |tx| {
2056 tx.comment(
2057 revision,
2058 body,
2059 reply_to,
2060 location,
2061 embeds.into_iter().collect(),
2062 )
2063 })
2064 }
2065
2066 pub fn react<G: Signer>(
2068 &mut self,
2069 revision: RevisionId,
2070 reaction: Reaction,
2071 location: Option<CodeLocation>,
2072 active: bool,
2073 signer: &G,
2074 ) -> Result<EntryId, Error> {
2075 self.transaction("React", signer, |tx| {
2076 tx.react(revision, reaction, location, active)
2077 })
2078 }
2079
2080 pub fn comment_edit<G: Signer, S: ToString>(
2082 &mut self,
2083 revision: RevisionId,
2084 comment: CommentId,
2085 body: S,
2086 embeds: impl IntoIterator<Item = Embed<Uri>>,
2087 signer: &G,
2088 ) -> Result<EntryId, Error> {
2089 self.transaction("Edit comment", signer, |tx| {
2090 tx.comment_edit(revision, comment, body, embeds.into_iter().collect())
2091 })
2092 }
2093
2094 pub fn comment_react<G: Signer>(
2096 &mut self,
2097 revision: RevisionId,
2098 comment: CommentId,
2099 reaction: Reaction,
2100 active: bool,
2101 signer: &G,
2102 ) -> Result<EntryId, Error> {
2103 self.transaction("React comment", signer, |tx| {
2104 tx.comment_react(revision, comment, reaction, active)
2105 })
2106 }
2107
2108 pub fn comment_redact<G: Signer>(
2110 &mut self,
2111 revision: RevisionId,
2112 comment: CommentId,
2113 signer: &G,
2114 ) -> Result<EntryId, Error> {
2115 self.transaction("Redact comment", signer, |tx| {
2116 tx.comment_redact(revision, comment)
2117 })
2118 }
2119
2120 pub fn review_comment<G: Signer, S: ToString>(
2122 &mut self,
2123 review: ReviewId,
2124 body: S,
2125 location: Option<CodeLocation>,
2126 reply_to: Option<CommentId>,
2127 embeds: impl IntoIterator<Item = Embed<Uri>>,
2128 signer: &G,
2129 ) -> Result<EntryId, Error> {
2130 self.transaction("Review comment", signer, |tx| {
2131 tx.review_comment(
2132 review,
2133 body,
2134 location,
2135 reply_to,
2136 embeds.into_iter().collect(),
2137 )
2138 })
2139 }
2140
2141 pub fn edit_review_comment<G: Signer, S: ToString>(
2143 &mut self,
2144 review: ReviewId,
2145 comment: EntryId,
2146 body: S,
2147 embeds: impl IntoIterator<Item = Embed<Uri>>,
2148 signer: &G,
2149 ) -> Result<EntryId, Error> {
2150 self.transaction("Edit review comment", signer, |tx| {
2151 tx.edit_review_comment(review, comment, body, embeds.into_iter().collect())
2152 })
2153 }
2154
2155 pub fn react_review_comment<G: Signer>(
2157 &mut self,
2158 review: ReviewId,
2159 comment: EntryId,
2160 reaction: Reaction,
2161 active: bool,
2162 signer: &G,
2163 ) -> Result<EntryId, Error> {
2164 self.transaction("React to review comment", signer, |tx| {
2165 tx.react_review_comment(review, comment, reaction, active)
2166 })
2167 }
2168
2169 pub fn redact_review_comment<G: Signer>(
2171 &mut self,
2172 review: ReviewId,
2173 comment: EntryId,
2174 signer: &G,
2175 ) -> Result<EntryId, Error> {
2176 self.transaction("Redact review comment", signer, |tx| {
2177 tx.redact_review_comment(review, comment)
2178 })
2179 }
2180
2181 pub fn review<G: Signer>(
2183 &mut self,
2184 revision: RevisionId,
2185 verdict: Option<Verdict>,
2186 summary: Option<String>,
2187 labels: Vec<Label>,
2188 signer: &G,
2189 ) -> Result<ReviewId, Error> {
2190 if verdict.is_none() && summary.is_none() {
2191 return Err(Error::EmptyReview);
2192 }
2193 self.transaction("Review", signer, |tx| {
2194 tx.review(revision, verdict, summary, labels)
2195 })
2196 .map(ReviewId)
2197 }
2198
2199 pub fn review_edit<G: Signer>(
2201 &mut self,
2202 review: ReviewId,
2203 verdict: Option<Verdict>,
2204 summary: Option<String>,
2205 labels: Vec<Label>,
2206 signer: &G,
2207 ) -> Result<EntryId, Error> {
2208 self.transaction("Edit review", signer, |tx| {
2209 tx.review_edit(review, verdict, summary, labels)
2210 })
2211 }
2212
2213 pub fn redact_review<G: Signer>(
2215 &mut self,
2216 review: ReviewId,
2217 signer: &G,
2218 ) -> Result<EntryId, Error> {
2219 self.transaction("Redact review", signer, |tx| tx.redact_review(review))
2220 }
2221
2222 pub fn resolve_review_comment<G: Signer>(
2224 &mut self,
2225 review: ReviewId,
2226 comment: CommentId,
2227 signer: &G,
2228 ) -> Result<EntryId, Error> {
2229 self.transaction("Resolve review comment", signer, |tx| {
2230 tx.review_comment_resolve(review, comment)
2231 })
2232 }
2233
2234 pub fn unresolve_review_comment<G: Signer>(
2236 &mut self,
2237 review: ReviewId,
2238 comment: CommentId,
2239 signer: &G,
2240 ) -> Result<EntryId, Error> {
2241 self.transaction("Unresolve review comment", signer, |tx| {
2242 tx.review_comment_unresolve(review, comment)
2243 })
2244 }
2245
2246 pub fn merge<G: Signer>(
2248 &mut self,
2249 revision: RevisionId,
2250 commit: git::Oid,
2251 signer: &G,
2252 ) -> Result<Merged<R>, Error> {
2253 let entry = self.transaction("Merge revision", signer, |tx| tx.merge(revision, commit))?;
2255
2256 Ok(Merged {
2257 entry,
2258 patch: self.id,
2259 stored: self.store.as_ref(),
2260 })
2261 }
2262
2263 pub fn update<G: Signer>(
2265 &mut self,
2266 description: impl ToString,
2267 base: impl Into<git::Oid>,
2268 oid: impl Into<git::Oid>,
2269 signer: &G,
2270 ) -> Result<RevisionId, Error> {
2271 self.transaction("Add revision", signer, |tx| {
2272 tx.revision(description, base, oid)
2273 })
2274 .map(RevisionId)
2275 }
2276
2277 pub fn lifecycle<G: Signer>(&mut self, state: Lifecycle, signer: &G) -> Result<EntryId, Error> {
2279 self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state))
2280 }
2281
2282 pub fn assign<G: Signer>(
2284 &mut self,
2285 assignees: BTreeSet<Did>,
2286 signer: &G,
2287 ) -> Result<EntryId, Error> {
2288 self.transaction("Assign", signer, |tx| tx.assign(assignees))
2289 }
2290
2291 pub fn archive<G: Signer>(&mut self, signer: &G) -> Result<bool, Error> {
2293 self.lifecycle(Lifecycle::Archived, signer)?;
2294
2295 Ok(true)
2296 }
2297
2298 pub fn unarchive<G: Signer>(&mut self, signer: &G) -> Result<bool, Error> {
2301 if !self.is_archived() {
2302 return Ok(false);
2303 }
2304 self.lifecycle(Lifecycle::Open, signer)?;
2305
2306 Ok(true)
2307 }
2308
2309 pub fn ready<G: Signer>(&mut self, signer: &G) -> Result<bool, Error> {
2312 if !self.is_draft() {
2313 return Ok(false);
2314 }
2315 self.lifecycle(Lifecycle::Open, signer)?;
2316
2317 Ok(true)
2318 }
2319
2320 pub fn unready<G: Signer>(&mut self, signer: &G) -> Result<bool, Error> {
2323 if !matches!(self.state(), State::Open { conflicts } if conflicts.is_empty()) {
2324 return Ok(false);
2325 }
2326 self.lifecycle(Lifecycle::Draft, signer)?;
2327
2328 Ok(true)
2329 }
2330
2331 pub fn label<G: Signer>(
2333 &mut self,
2334 labels: impl IntoIterator<Item = Label>,
2335 signer: &G,
2336 ) -> Result<EntryId, Error> {
2337 self.transaction("Label", signer, |tx| tx.label(labels))
2338 }
2339}
2340
2341impl<'a, 'g, R, C> Deref for PatchMut<'a, 'g, R, C> {
2342 type Target = Patch;
2343
2344 fn deref(&self) -> &Self::Target {
2345 &self.patch
2346 }
2347}
2348
2349#[derive(Debug, Default, PartialEq, Eq, Serialize)]
2351#[serde(rename_all = "camelCase")]
2352pub struct PatchCounts {
2353 pub open: usize,
2354 pub draft: usize,
2355 pub archived: usize,
2356 pub merged: usize,
2357}
2358
2359impl PatchCounts {
2360 pub fn total(&self) -> usize {
2362 self.open + self.draft + self.archived + self.merged
2363 }
2364}
2365
2366#[derive(Debug, PartialEq, Eq)]
2370pub struct ByRevision {
2371 pub id: PatchId,
2372 pub patch: Patch,
2373 pub revision_id: RevisionId,
2374 pub revision: Revision,
2375}
2376
2377pub struct Patches<'a, R> {
2378 raw: store::Store<'a, Patch, R>,
2379}
2380
2381impl<'a, R> Deref for Patches<'a, R> {
2382 type Target = store::Store<'a, Patch, R>;
2383
2384 fn deref(&self) -> &Self::Target {
2385 &self.raw
2386 }
2387}
2388
2389impl<'a, R> HasRepoId for Patches<'a, R>
2390where
2391 R: ReadRepository,
2392{
2393 fn rid(&self) -> RepoId {
2394 self.as_ref().id()
2395 }
2396}
2397
2398impl<'a, R> Patches<'a, R>
2399where
2400 R: ReadRepository + cob::Store,
2401{
2402 pub fn open(repository: &'a R) -> Result<Self, RepositoryError> {
2404 let identity = repository.identity_head()?;
2405 let raw = store::Store::open(repository)?.identity(identity);
2406
2407 Ok(Self { raw })
2408 }
2409
2410 pub fn counts(&self) -> Result<PatchCounts, store::Error> {
2412 let all = self.all()?;
2413 let state_groups =
2414 all.filter_map(|s| s.ok())
2415 .fold(PatchCounts::default(), |mut state, (_, p)| {
2416 match p.state() {
2417 State::Draft => state.draft += 1,
2418 State::Open { .. } => state.open += 1,
2419 State::Archived => state.archived += 1,
2420 State::Merged { .. } => state.merged += 1,
2421 }
2422 state
2423 });
2424
2425 Ok(state_groups)
2426 }
2427
2428 pub fn find_by_revision(&self, revision: &RevisionId) -> Result<Option<ByRevision>, Error> {
2430 let p_id = ObjectId::from(revision.into_inner());
2432 if let Some(p) = self.get(&p_id)? {
2433 return Ok(p.revision(revision).map(|r| ByRevision {
2434 id: p_id,
2435 patch: p.clone(),
2436 revision_id: *revision,
2437 revision: r.clone(),
2438 }));
2439 }
2440 let result = self
2441 .all()?
2442 .filter_map(|result| result.ok())
2443 .find_map(|(p_id, p)| {
2444 p.revision(revision).map(|r| ByRevision {
2445 id: p_id,
2446 patch: p.clone(),
2447 revision_id: *revision,
2448 revision: r.clone(),
2449 })
2450 });
2451
2452 Ok(result)
2453 }
2454
2455 pub fn get(&self, id: &ObjectId) -> Result<Option<Patch>, store::Error> {
2457 self.raw.get(id)
2458 }
2459
2460 pub fn proposed(&self) -> Result<impl Iterator<Item = (PatchId, Patch)> + '_, Error> {
2462 let all = self.all()?;
2463
2464 Ok(all
2465 .into_iter()
2466 .filter_map(|result| result.ok())
2467 .filter(|(_, p)| p.is_open()))
2468 }
2469
2470 pub fn proposed_by<'b>(
2472 &'b self,
2473 who: &'b Did,
2474 ) -> Result<impl Iterator<Item = (PatchId, Patch)> + '_, Error> {
2475 Ok(self
2476 .proposed()?
2477 .filter(move |(_, p)| p.author().id() == who))
2478 }
2479}
2480
2481impl<'a, R> Patches<'a, R>
2482where
2483 R: ReadRepository + SignRepository + cob::Store,
2484{
2485 pub fn create<'g, C, G>(
2487 &'g mut self,
2488 title: impl ToString,
2489 description: impl ToString,
2490 target: MergeTarget,
2491 base: impl Into<git::Oid>,
2492 oid: impl Into<git::Oid>,
2493 labels: &[Label],
2494 cache: &'g mut C,
2495 signer: &G,
2496 ) -> Result<PatchMut<'a, 'g, R, C>, Error>
2497 where
2498 C: cob::cache::Update<Patch>,
2499 G: Signer,
2500 {
2501 self._create(
2502 title,
2503 description,
2504 target,
2505 base,
2506 oid,
2507 labels,
2508 Lifecycle::default(),
2509 cache,
2510 signer,
2511 )
2512 }
2513
2514 pub fn draft<'g, C, G: Signer>(
2516 &'g mut self,
2517 title: impl ToString,
2518 description: impl ToString,
2519 target: MergeTarget,
2520 base: impl Into<git::Oid>,
2521 oid: impl Into<git::Oid>,
2522 labels: &[Label],
2523 cache: &'g mut C,
2524 signer: &G,
2525 ) -> Result<PatchMut<'a, 'g, R, C>, Error>
2526 where
2527 C: cob::cache::Update<Patch>,
2528 {
2529 self._create(
2530 title,
2531 description,
2532 target,
2533 base,
2534 oid,
2535 labels,
2536 Lifecycle::Draft,
2537 cache,
2538 signer,
2539 )
2540 }
2541
2542 pub fn get_mut<'g, C>(
2544 &'g mut self,
2545 id: &ObjectId,
2546 cache: &'g mut C,
2547 ) -> Result<PatchMut<'a, 'g, R, C>, store::Error> {
2548 let patch = self
2549 .raw
2550 .get(id)?
2551 .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
2552
2553 Ok(PatchMut {
2554 id: *id,
2555 patch,
2556 store: self,
2557 cache,
2558 })
2559 }
2560
2561 fn _create<'g, C, G: Signer>(
2563 &'g mut self,
2564 title: impl ToString,
2565 description: impl ToString,
2566 target: MergeTarget,
2567 base: impl Into<git::Oid>,
2568 oid: impl Into<git::Oid>,
2569 labels: &[Label],
2570 state: Lifecycle,
2571 cache: &'g mut C,
2572 signer: &G,
2573 ) -> Result<PatchMut<'a, 'g, R, C>, Error>
2574 where
2575 C: cob::cache::Update<Patch>,
2576 {
2577 let (id, patch) = Transaction::initial("Create patch", &mut self.raw, signer, |tx, _| {
2578 tx.revision(description, base, oid)?;
2579 tx.edit(title, target)?;
2580
2581 if !labels.is_empty() {
2582 tx.label(labels.to_owned())?;
2583 }
2584 if state != Lifecycle::default() {
2585 tx.lifecycle(state)?;
2586 }
2587 Ok(())
2588 })?;
2589 cache
2590 .update(&self.raw.as_ref().id(), &id, &patch)
2591 .map_err(|e| Error::CacheUpdate { id, err: e.into() })?;
2592
2593 Ok(PatchMut {
2594 id,
2595 patch,
2596 store: self,
2597 cache,
2598 })
2599 }
2600}
2601
2602mod ser {
2604 use std::collections::{BTreeMap, BTreeSet};
2605
2606 use serde::ser::SerializeSeq;
2607
2608 use crate::cob::{thread::Reactions, ActorId, CodeLocation};
2609
2610 #[derive(Debug, serde::Serialize, serde::Deserialize)]
2614 #[serde(rename_all = "camelCase")]
2615 struct Reaction {
2616 location: Option<CodeLocation>,
2617 emoji: super::Reaction,
2618 authors: Vec<ActorId>,
2619 }
2620
2621 impl Reaction {
2622 fn as_revision_reactions(
2623 reactions: Vec<Reaction>,
2624 ) -> BTreeMap<Option<CodeLocation>, Reactions> {
2625 reactions.into_iter().fold(
2626 BTreeMap::<Option<CodeLocation>, Reactions>::new(),
2627 |mut reactions,
2628 Reaction {
2629 location,
2630 emoji,
2631 authors,
2632 }| {
2633 let mut inner = authors
2634 .into_iter()
2635 .map(|author| (author, emoji))
2636 .collect::<BTreeSet<_>>();
2637 let entry = reactions.entry(location).or_default();
2638 entry.append(&mut inner);
2639 reactions
2640 },
2641 )
2642 }
2643 }
2644
2645 pub fn serialize_reactions<S>(
2651 reactions: &BTreeMap<Option<CodeLocation>, Reactions>,
2652 serializer: S,
2653 ) -> Result<S::Ok, S::Error>
2654 where
2655 S: serde::Serializer,
2656 {
2657 let reactions = reactions
2658 .iter()
2659 .flat_map(|(location, reaction)| {
2660 let reactions = reaction.iter().fold(
2661 BTreeMap::new(),
2662 |mut acc: BTreeMap<&super::Reaction, Vec<_>>, (author, emoji)| {
2663 acc.entry(emoji).or_default().push(*author);
2664 acc
2665 },
2666 );
2667 reactions
2668 .into_iter()
2669 .map(|(emoji, authors)| Reaction {
2670 location: location.clone(),
2671 emoji: *emoji,
2672 authors,
2673 })
2674 .collect::<Vec<_>>()
2675 })
2676 .collect::<Vec<_>>();
2677 let mut s = serializer.serialize_seq(Some(reactions.len()))?;
2678 for r in &reactions {
2679 s.serialize_element(r)?;
2680 }
2681 s.end()
2682 }
2683
2684 pub fn deserialize_reactions<'de, D>(
2690 deserializer: D,
2691 ) -> Result<BTreeMap<Option<CodeLocation>, Reactions>, D::Error>
2692 where
2693 D: serde::Deserializer<'de>,
2694 {
2695 struct ReactionsVisitor;
2696
2697 impl<'de> serde::de::Visitor<'de> for ReactionsVisitor {
2698 type Value = Vec<Reaction>;
2699
2700 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
2701 formatter.write_str("a reaction of the form {'location', 'emoji', 'authors'}")
2702 }
2703
2704 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
2705 where
2706 A: serde::de::SeqAccess<'de>,
2707 {
2708 let mut reactions = Vec::new();
2709 while let Some(reaction) = seq.next_element()? {
2710 reactions.push(reaction);
2711 }
2712 Ok(reactions)
2713 }
2714 }
2715
2716 let reactions = deserializer.deserialize_seq(ReactionsVisitor)?;
2717 Ok(Reaction::as_revision_reactions(reactions))
2718 }
2719}
2720
2721#[cfg(test)]
2722#[allow(clippy::unwrap_used)]
2723mod test {
2724 use std::path::PathBuf;
2725 use std::str::FromStr;
2726 use std::vec;
2727
2728 use pretty_assertions::assert_eq;
2729
2730 use super::*;
2731 use crate::cob::common::CodeRange;
2732 use crate::cob::test::Actor;
2733 use crate::crypto::test::signer::MockSigner;
2734 use crate::identity;
2735 use crate::patch::cache::Patches as _;
2736 use crate::profile::env;
2737 use crate::test;
2738 use crate::test::arbitrary;
2739 use crate::test::arbitrary::gen;
2740 use crate::test::storage::MockRepository;
2741
2742 use cob::migrate;
2743
2744 #[test]
2745 fn test_json_serialization() {
2746 let edit = Action::Label {
2747 labels: BTreeSet::new(),
2748 };
2749 assert_eq!(
2750 serde_json::to_string(&edit).unwrap(),
2751 String::from(r#"{"type":"label","labels":[]}"#)
2752 );
2753 }
2754
2755 #[test]
2756 fn test_reactions_json_serialization() {
2757 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
2758 #[serde(rename_all = "camelCase")]
2759 struct TestReactions {
2760 #[serde(
2761 serialize_with = "super::ser::serialize_reactions",
2762 deserialize_with = "super::ser::deserialize_reactions"
2763 )]
2764 inner: BTreeMap<Option<CodeLocation>, Reactions>,
2765 }
2766
2767 let reactions = TestReactions {
2768 inner: [(
2769 None,
2770 [
2771 (
2772 "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"
2773 .parse()
2774 .unwrap(),
2775 Reaction::new('🚀').unwrap(),
2776 ),
2777 (
2778 "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
2779 .parse()
2780 .unwrap(),
2781 Reaction::new('🙏').unwrap(),
2782 ),
2783 ]
2784 .into_iter()
2785 .collect(),
2786 )]
2787 .into_iter()
2788 .collect(),
2789 };
2790
2791 assert_eq!(
2792 reactions,
2793 serde_json::from_str(&serde_json::to_string(&reactions).unwrap()).unwrap()
2794 );
2795 }
2796
2797 #[test]
2798 fn test_patch_create_and_get() {
2799 let alice = test::setup::NodeWithRepo::default();
2800 let checkout = alice.repo.checkout();
2801 let branch = checkout.branch_with([("README", b"Hello World!")]);
2802 let mut patches = Cache::no_cache(&*alice.repo).unwrap();
2803 let author: Did = alice.signer.public_key().into();
2804 let target = MergeTarget::Delegates;
2805 let patch = patches
2806 .create(
2807 "My first patch",
2808 "Blah blah blah.",
2809 target,
2810 branch.base,
2811 branch.oid,
2812 &[],
2813 &alice.signer,
2814 )
2815 .unwrap();
2816
2817 let patch_id = patch.id;
2818 let patch = patches.get(&patch_id).unwrap().unwrap();
2819
2820 assert_eq!(patch.title(), "My first patch");
2821 assert_eq!(patch.description(), "Blah blah blah.");
2822 assert_eq!(patch.author().id(), &author);
2823 assert_eq!(patch.state(), &State::Open { conflicts: vec![] });
2824 assert_eq!(patch.target(), target);
2825 assert_eq!(patch.version(), 0);
2826
2827 let (rev_id, revision) = patch.latest();
2828
2829 assert_eq!(revision.author.id(), &author);
2830 assert_eq!(revision.description(), "Blah blah blah.");
2831 assert_eq!(revision.discussion.len(), 0);
2832 assert_eq!(revision.oid, branch.oid);
2833 assert_eq!(revision.base, branch.base);
2834
2835 let ByRevision { id, .. } = patches.find_by_revision(&rev_id).unwrap().unwrap();
2836 assert_eq!(id, patch_id);
2837 }
2838
2839 #[test]
2840 fn test_patch_discussion() {
2841 let alice = test::setup::NodeWithRepo::default();
2842 let checkout = alice.repo.checkout();
2843 let branch = checkout.branch_with([("README", b"Hello World!")]);
2844 let mut patches = Cache::no_cache(&*alice.repo).unwrap();
2845 let patch = patches
2846 .create(
2847 "My first patch",
2848 "Blah blah blah.",
2849 MergeTarget::Delegates,
2850 branch.base,
2851 branch.oid,
2852 &[],
2853 &alice.signer,
2854 )
2855 .unwrap();
2856
2857 let id = patch.id;
2858 let mut patch = patches.get_mut(&id).unwrap();
2859 let (revision_id, _) = patch.revisions().last().unwrap();
2860 assert!(
2861 patch
2862 .comment(revision_id, "patch comment", None, None, [], &alice.signer)
2863 .is_ok(),
2864 "can comment on patch"
2865 );
2866
2867 let (_, revision) = patch.revisions().last().unwrap();
2868 let (_, comment) = revision.discussion.first().unwrap();
2869 assert_eq!("patch comment", comment.body(), "comment body untouched");
2870 }
2871
2872 #[test]
2873 fn test_patch_merge() {
2874 let alice = test::setup::NodeWithRepo::default();
2875 let checkout = alice.repo.checkout();
2876 let branch = checkout.branch_with([("README", b"Hello World!")]);
2877 let mut patches = Cache::no_cache(&*alice.repo).unwrap();
2878 let mut patch = patches
2879 .create(
2880 "My first patch",
2881 "Blah blah blah.",
2882 MergeTarget::Delegates,
2883 branch.base,
2884 branch.oid,
2885 &[],
2886 &alice.signer,
2887 )
2888 .unwrap();
2889
2890 let id = patch.id;
2891 let (rid, _) = patch.revisions().next().unwrap();
2892 let _merge = patch.merge(rid, branch.base, &alice.signer).unwrap();
2893 let patch = patches.get(&id).unwrap().unwrap();
2894
2895 let merges = patch.merges.iter().collect::<Vec<_>>();
2896 assert_eq!(merges.len(), 1);
2897
2898 let (merger, merge) = merges.first().unwrap();
2899 assert_eq!(*merger, alice.signer.public_key());
2900 assert_eq!(merge.commit, branch.base);
2901 }
2902
2903 #[test]
2904 fn test_patch_review() {
2905 let alice = test::setup::NodeWithRepo::default();
2906 let checkout = alice.repo.checkout();
2907 let branch = checkout.branch_with([("README", b"Hello World!")]);
2908 let mut patches = Cache::no_cache(&*alice.repo).unwrap();
2909 let mut patch = patches
2910 .create(
2911 "My first patch",
2912 "Blah blah blah.",
2913 MergeTarget::Delegates,
2914 branch.base,
2915 branch.oid,
2916 &[],
2917 &alice.signer,
2918 )
2919 .unwrap();
2920
2921 let (revision_id, _) = patch.latest();
2922 let review_id = patch
2923 .review(
2924 revision_id,
2925 Some(Verdict::Accept),
2926 Some("LGTM".to_owned()),
2927 vec![],
2928 &alice.signer,
2929 )
2930 .unwrap();
2931
2932 let id = patch.id;
2933 let mut patch = patches.get_mut(&id).unwrap();
2934 let (_, revision) = patch.latest();
2935 assert_eq!(revision.reviews.len(), 1);
2936
2937 let review = revision.review_by(alice.signer.public_key()).unwrap();
2938 assert_eq!(review.verdict(), Some(Verdict::Accept));
2939 assert_eq!(review.summary(), Some("LGTM"));
2940
2941 patch.redact_review(review_id, &alice.signer).unwrap();
2942 patch.reload().unwrap();
2943
2944 let (_, revision) = patch.latest();
2945 assert_eq!(revision.reviews().count(), 0);
2946
2947 patch.redact_review(review_id, &alice.signer).unwrap();
2949 patch
2951 .redact_review(ReviewId(arbitrary::entry_id()), &alice.signer)
2952 .unwrap_err();
2953 }
2954
2955 #[test]
2956 fn test_patch_review_revision_redact() {
2957 let alice = test::setup::NodeWithRepo::default();
2958 let checkout = alice.repo.checkout();
2959 let branch = checkout.branch_with([("README", b"Hello World!")]);
2960 let mut patches = Cache::no_cache(&*alice.repo).unwrap();
2961 let mut patch = patches
2962 .create(
2963 "My first patch",
2964 "Blah blah blah.",
2965 MergeTarget::Delegates,
2966 branch.base,
2967 branch.oid,
2968 &[],
2969 &alice.signer,
2970 )
2971 .unwrap();
2972
2973 let update = checkout.branch_with([("README", b"Hello Radicle!")]);
2974 let updated = patch
2975 .update("I've made changes.", branch.base, update.oid, &alice.signer)
2976 .unwrap();
2977
2978 let review = patch
2980 .review(updated, Some(Verdict::Accept), None, vec![], &alice.signer)
2981 .unwrap();
2982 patch.redact(updated, &alice.signer).unwrap();
2983 patch.redact_review(review, &alice.signer).unwrap();
2984 }
2985
2986 #[test]
2987 fn test_revision_review_merge_redacted() {
2988 let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
2989 let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
2990 let mut alice = Actor::new(MockSigner::default());
2991 let rid = gen::<RepoId>(1);
2992 let doc = RawDoc::new(
2993 gen::<Project>(1),
2994 vec![alice.did()],
2995 1,
2996 identity::Visibility::Public,
2997 )
2998 .verified()
2999 .unwrap();
3000 let repo = MockRepository::new(rid, doc);
3001
3002 let a1 = alice.op::<Patch>([
3003 Action::Revision {
3004 description: String::new(),
3005 base,
3006 oid,
3007 resolves: Default::default(),
3008 },
3009 Action::Edit {
3010 title: String::from("My patch"),
3011 target: MergeTarget::Delegates,
3012 },
3013 ]);
3014 let a2 = alice.op::<Patch>([Action::Revision {
3015 description: String::from("Second revision"),
3016 base,
3017 oid,
3018 resolves: Default::default(),
3019 }]);
3020 let a3 = alice.op::<Patch>([Action::RevisionRedact {
3021 revision: RevisionId(a2.id()),
3022 }]);
3023 let a4 = alice.op::<Patch>([Action::Review {
3024 revision: RevisionId(a2.id()),
3025 summary: None,
3026 verdict: Some(Verdict::Accept),
3027 labels: vec![],
3028 }]);
3029 let a5 = alice.op::<Patch>([Action::Merge {
3030 revision: RevisionId(a2.id()),
3031 commit: oid,
3032 }]);
3033
3034 let mut patch = Patch::from_ops([a1, a2], &repo).unwrap();
3035 assert_eq!(patch.revisions().count(), 2);
3036
3037 patch.op(a3, [], &repo).unwrap();
3038 assert_eq!(patch.revisions().count(), 1);
3039
3040 patch.op(a4, [], &repo).unwrap();
3041 patch.op(a5, [], &repo).unwrap();
3042 }
3043
3044 #[test]
3045 fn test_revision_edit_redact() {
3046 let base = arbitrary::oid();
3047 let oid = arbitrary::oid();
3048 let repo = gen::<MockRepository>(1);
3049 let time = env::local_time();
3050 let alice = MockSigner::default();
3051 let bob = MockSigner::default();
3052 let mut h0: cob::test::HistoryBuilder<Patch> = cob::test::history(
3053 &[
3054 Action::Revision {
3055 description: String::from("Original"),
3056 base,
3057 oid,
3058 resolves: Default::default(),
3059 },
3060 Action::Edit {
3061 title: String::from("Some patch"),
3062 target: MergeTarget::Delegates,
3063 },
3064 ],
3065 time.into(),
3066 &alice,
3067 );
3068 let r1 = h0.commit(
3069 &Action::Revision {
3070 description: String::from("New"),
3071 base,
3072 oid,
3073 resolves: Default::default(),
3074 },
3075 &alice,
3076 );
3077 let patch = Patch::from_history(&h0, &repo).unwrap();
3078 assert_eq!(patch.revisions().count(), 2);
3079
3080 let mut h1 = h0.clone();
3081 h1.commit(
3082 &Action::RevisionRedact {
3083 revision: RevisionId(r1),
3084 },
3085 &alice,
3086 );
3087
3088 let mut h2 = h0.clone();
3089 h2.commit(
3090 &Action::RevisionEdit {
3091 revision: RevisionId(*h0.root().id()),
3092 description: String::from("Edited"),
3093 embeds: Vec::default(),
3094 },
3095 &bob,
3096 );
3097
3098 h0.merge(h1);
3099 h0.merge(h2);
3100
3101 let patch = Patch::from_history(&h0, &repo).unwrap();
3102 assert_eq!(patch.revisions().count(), 1);
3103 }
3104
3105 #[test]
3106 fn test_revision_reaction() {
3107 let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
3108 let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
3109 let mut alice = Actor::new(MockSigner::default());
3110 let repo = gen::<MockRepository>(1);
3111 let reaction = Reaction::new('👍').expect("failed to create a reaction");
3112
3113 let a1 = alice.op::<Patch>([
3114 Action::Revision {
3115 description: String::new(),
3116 base,
3117 oid,
3118 resolves: Default::default(),
3119 },
3120 Action::Edit {
3121 title: String::from("My patch"),
3122 target: MergeTarget::Delegates,
3123 },
3124 ]);
3125 let a2 = alice.op::<Patch>([Action::RevisionReact {
3126 revision: RevisionId(a1.id()),
3127 location: None,
3128 reaction,
3129 active: true,
3130 }]);
3131 let patch = Patch::from_ops([a1, a2], &repo).unwrap();
3132
3133 let (_, r1) = patch.revisions().next().unwrap();
3134 assert!(!r1.reactions.is_empty());
3135
3136 let mut reactions = r1.reactions.get(&None).unwrap().clone();
3137 assert!(!reactions.is_empty());
3138
3139 let (_, first_reaction) = reactions.pop_first().unwrap();
3140 assert_eq!(first_reaction, reaction);
3141 }
3142
3143 #[test]
3144 fn test_patch_review_edit() {
3145 let alice = test::setup::NodeWithRepo::default();
3146 let checkout = alice.repo.checkout();
3147 let branch = checkout.branch_with([("README", b"Hello World!")]);
3148 let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3149 let mut patch = patches
3150 .create(
3151 "My first patch",
3152 "Blah blah blah.",
3153 MergeTarget::Delegates,
3154 branch.base,
3155 branch.oid,
3156 &[],
3157 &alice.signer,
3158 )
3159 .unwrap();
3160
3161 let (rid, _) = patch.latest();
3162 let review = patch
3163 .review(
3164 rid,
3165 Some(Verdict::Accept),
3166 Some("LGTM".to_owned()),
3167 vec![],
3168 &alice.signer,
3169 )
3170 .unwrap();
3171 patch
3172 .review_edit(
3173 review,
3174 Some(Verdict::Reject),
3175 Some("Whoops!".to_owned()),
3176 vec![],
3177 &alice.signer,
3178 )
3179 .unwrap(); let (_, revision) = patch.latest();
3182 let review = revision.review_by(alice.signer.public_key()).unwrap();
3183 assert_eq!(review.verdict(), Some(Verdict::Reject));
3184 assert_eq!(review.summary(), Some("Whoops!"));
3185 }
3186
3187 #[test]
3188 fn test_patch_review_duplicate() {
3189 let alice = test::setup::NodeWithRepo::default();
3190 let checkout = alice.repo.checkout();
3191 let branch = checkout.branch_with([("README", b"Hello World!")]);
3192 let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3193 let mut patch = patches
3194 .create(
3195 "My first patch",
3196 "Blah blah blah.",
3197 MergeTarget::Delegates,
3198 branch.base,
3199 branch.oid,
3200 &[],
3201 &alice.signer,
3202 )
3203 .unwrap();
3204
3205 let (rid, _) = patch.latest();
3206 patch
3207 .review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
3208 .unwrap();
3209 patch
3210 .review(rid, Some(Verdict::Reject), None, vec![], &alice.signer)
3211 .unwrap(); let (_, revision) = patch.latest();
3214 let review = revision.review_by(alice.signer.public_key()).unwrap();
3215 assert_eq!(review.verdict(), Some(Verdict::Accept));
3216 }
3217
3218 #[test]
3219 fn test_patch_review_edit_comment() {
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 "My first patch",
3227 "Blah blah blah.",
3228 MergeTarget::Delegates,
3229 branch.base,
3230 branch.oid,
3231 &[],
3232 &alice.signer,
3233 )
3234 .unwrap();
3235
3236 let (rid, _) = patch.latest();
3237 let review = patch
3238 .review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
3239 .unwrap();
3240 patch
3241 .review_comment(review, "First comment!", None, None, [], &alice.signer)
3242 .unwrap();
3243
3244 let _review = patch
3245 .review_edit(review, Some(Verdict::Reject), None, vec![], &alice.signer)
3246 .unwrap();
3247 patch
3248 .review_comment(review, "Second comment!", None, None, [], &alice.signer)
3249 .unwrap();
3250
3251 let (_, revision) = patch.latest();
3252 let review = revision.review_by(alice.signer.public_key()).unwrap();
3253 assert_eq!(review.verdict(), Some(Verdict::Reject));
3254 assert_eq!(review.comments().count(), 2);
3255 assert_eq!(review.comments().nth(0).unwrap().1.body(), "First comment!");
3256 assert_eq!(
3257 review.comments().nth(1).unwrap().1.body(),
3258 "Second comment!"
3259 );
3260 }
3261
3262 #[test]
3263 fn test_patch_review_comment() {
3264 let alice = test::setup::NodeWithRepo::default();
3265 let checkout = alice.repo.checkout();
3266 let branch = checkout.branch_with([("README", b"Hello World!")]);
3267 let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3268 let mut patch = patches
3269 .create(
3270 "My first patch",
3271 "Blah blah blah.",
3272 MergeTarget::Delegates,
3273 branch.base,
3274 branch.oid,
3275 &[],
3276 &alice.signer,
3277 )
3278 .unwrap();
3279
3280 let (rid, _) = patch.latest();
3281 let location = CodeLocation {
3282 commit: branch.oid,
3283 path: PathBuf::from_str("README").unwrap(),
3284 old: None,
3285 new: Some(CodeRange::Lines { range: 5..8 }),
3286 };
3287 let review = patch
3288 .review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
3289 .unwrap();
3290 patch
3291 .review_comment(
3292 review,
3293 "I like these lines of code",
3294 Some(location.clone()),
3295 None,
3296 [],
3297 &alice.signer,
3298 )
3299 .unwrap();
3300
3301 let (_, revision) = patch.latest();
3302 let review = revision.review_by(alice.signer.public_key()).unwrap();
3303 let (_, comment) = review.comments().next().unwrap();
3304
3305 assert_eq!(comment.body(), "I like these lines of code");
3306 assert_eq!(comment.location(), Some(&location));
3307 }
3308
3309 #[test]
3310 fn test_patch_review_remove_summary() {
3311 let alice = test::setup::NodeWithRepo::default();
3312 let checkout = alice.repo.checkout();
3313 let branch = checkout.branch_with([("README", b"Hello World!")]);
3314 let mut patches = Cache::no_cache(&*alice.repo).unwrap();
3315 let mut patch = patches
3316 .create(
3317 "My first patch",
3318 "Blah blah blah.",
3319 MergeTarget::Delegates,
3320 branch.base,
3321 branch.oid,
3322 &[],
3323 &alice.signer,
3324 )
3325 .unwrap();
3326
3327 let (rid, _) = patch.latest();
3328 let review = patch
3329 .review(
3330 rid,
3331 Some(Verdict::Accept),
3332 Some("Nah".to_owned()),
3333 vec![],
3334 &alice.signer,
3335 )
3336 .unwrap();
3337 patch
3338 .review_edit(review, Some(Verdict::Accept), None, vec![], &alice.signer)
3339 .unwrap();
3340
3341 let id = patch.id;
3342 let patch = patches.get_mut(&id).unwrap();
3343 let (_, revision) = patch.latest();
3344 let review = revision.review_by(alice.signer.public_key()).unwrap();
3345
3346 assert_eq!(review.verdict(), Some(Verdict::Accept));
3347 assert_eq!(review.summary(), None);
3348 }
3349
3350 #[test]
3351 fn test_patch_update() {
3352 let alice = test::setup::NodeWithRepo::default();
3353 let checkout = alice.repo.checkout();
3354 let branch = checkout.branch_with([("README", b"Hello World!")]);
3355 let mut patches = {
3356 let path = alice.tmp.path().join("cobs.db");
3357 let mut db = cob::cache::Store::open(path).unwrap();
3358 let store = cob::patch::Patches::open(&*alice.repo).unwrap();
3359
3360 db.migrate(migrate::ignore).unwrap();
3361 cob::patch::Cache::open(store, db)
3362 };
3363 let mut patch = patches
3364 .create(
3365 "My first patch",
3366 "Blah blah blah.",
3367 MergeTarget::Delegates,
3368 branch.base,
3369 branch.oid,
3370 &[],
3371 &alice.signer,
3372 )
3373 .unwrap();
3374
3375 assert_eq!(patch.description(), "Blah blah blah.");
3376 assert_eq!(patch.version(), 0);
3377
3378 let update = checkout.branch_with([("README", b"Hello Radicle!")]);
3379 let _ = patch
3380 .update("I've made changes.", branch.base, update.oid, &alice.signer)
3381 .unwrap();
3382
3383 let id = patch.id;
3384 let patch = patches.get(&id).unwrap().unwrap();
3385 assert_eq!(patch.version(), 1);
3386 assert_eq!(patch.revisions.len(), 2);
3387 assert_eq!(patch.revisions().count(), 2);
3388 assert_eq!(
3389 patch.revisions().nth(0).unwrap().1.description(),
3390 "Blah blah blah."
3391 );
3392 assert_eq!(
3393 patch.revisions().nth(1).unwrap().1.description(),
3394 "I've made changes."
3395 );
3396
3397 let (_, revision) = patch.latest();
3398
3399 assert_eq!(patch.version(), 1);
3400 assert_eq!(revision.oid, update.oid);
3401 assert_eq!(revision.description(), "I've made changes.");
3402 }
3403
3404 #[test]
3405 fn test_patch_redact() {
3406 let alice = test::setup::Node::default();
3407 let repo = alice.project();
3408 let branch = repo
3409 .checkout()
3410 .branch_with([("README.md", b"Hello, World!")]);
3411 let mut patches = Cache::no_cache(&*repo).unwrap();
3412 let mut patch = patches
3413 .create(
3414 "My first patch",
3415 "Blah blah blah.",
3416 MergeTarget::Delegates,
3417 branch.base,
3418 branch.oid,
3419 &[],
3420 &alice.signer,
3421 )
3422 .unwrap();
3423 let patch_id = patch.id;
3424
3425 let update = repo
3426 .checkout()
3427 .branch_with([("README.md", b"Hello, Radicle!")]);
3428 let revision_id = patch
3429 .update("I've made changes.", branch.base, update.oid, &alice.signer)
3430 .unwrap();
3431 assert_eq!(patch.revisions().count(), 2);
3432
3433 patch.redact(revision_id, &alice.signer).unwrap();
3434 assert_eq!(patch.latest().0, RevisionId(*patch_id));
3435 assert_eq!(patch.revisions().count(), 1);
3436
3437 assert_eq!(patch.latest(), patch.root());
3439 assert!(patch.redact(patch.latest().0, &alice.signer).is_err());
3440 }
3441
3442 #[test]
3443 fn test_json() {
3444 use serde_json::json;
3445
3446 assert_eq!(
3447 serde_json::to_value(Action::Lifecycle {
3448 state: Lifecycle::Draft
3449 })
3450 .unwrap(),
3451 json!({
3452 "type": "lifecycle",
3453 "state": { "status": "draft" }
3454 })
3455 );
3456
3457 let revision = RevisionId(arbitrary::entry_id());
3458 assert_eq!(
3459 serde_json::to_value(Action::Review {
3460 revision,
3461 summary: None,
3462 verdict: None,
3463 labels: vec![],
3464 })
3465 .unwrap(),
3466 json!({
3467 "type": "review",
3468 "revision": revision,
3469 })
3470 );
3471
3472 assert_eq!(
3473 serde_json::to_value(CodeRange::Lines { range: 4..8 }).unwrap(),
3474 json!({
3475 "type": "lines",
3476 "range": { "start": 4, "end": 8 },
3477 })
3478 );
3479 }
3480}