1pub mod cache;
2
3use std::collections::BTreeSet;
4use std::ops::Deref;
5use std::str::FromStr;
6use std::sync::LazyLock;
7
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11use crate::cob;
12use crate::cob::common::{Author, Authorization, Label, Reaction, Timestamp, Uri};
13use crate::cob::store::Transaction;
14use crate::cob::store::{Cob, CobAction};
15use crate::cob::thread::{Comment, CommentId, Thread};
16use crate::cob::{op, store, ActorId, Embed, EntryId, ObjectId, TypeName};
17use crate::cob::{thread, TitleError};
18use crate::identity::doc::DocError;
19use crate::node::device::Device;
20use crate::node::NodeId;
21use crate::prelude::{Did, Doc, ReadRepository, RepoId};
22use crate::storage;
23use crate::storage::{HasRepoId, RepositoryError, WriteRepository};
24
25pub use cache::Cache;
26
27pub type Op = cob::Op<Action>;
29
30pub static TYPENAME: LazyLock<TypeName> =
32 LazyLock::new(|| FromStr::from_str("xyz.radicle.issue").expect("type name is valid"));
33
34pub type IssueId = ObjectId;
36
37pub type IssueStream<'a> = cob::stream::Stream<'a, Action>;
38
39impl<'a> IssueStream<'a> {
40 pub fn init(issue: IssueId, store: &'a storage::git::Repository) -> Self {
41 let history = cob::stream::CobRange::new(&TYPENAME, &issue);
42 Self::new(&store.backend, history, TYPENAME.clone())
43 }
44}
45
46#[derive(Error, Debug)]
48pub enum Error {
49 #[error("identity doc failed to load: {0}")]
51 Doc(#[from] DocError),
52 #[error("thread apply failed: {0}")]
53 Thread(#[from] thread::Error),
54 #[error("store: {0}")]
55 Store(#[from] store::Error),
56 #[error("invalid issue title due to: {0}")]
57 TitleError(#[from] TitleError),
58 #[error("{0} not authorized to apply {1:?}")]
60 NotAuthorized(ActorId, Action),
61 #[error("action is not allowed: {0}")]
63 NotAllowed(EntryId),
64 #[error("invalid title: {0:?}")]
66 InvalidTitle(String),
67 #[error("identity document missing")]
69 MissingIdentity,
70 #[error("initialization failed: {0}")]
72 Init(&'static str),
73 #[error("op decoding failed: {0}")]
75 Op(#[from] op::OpEncodingError),
76 #[error("failed to update issue {id} in cache: {err}")]
77 CacheUpdate {
78 id: IssueId,
79 #[source]
80 err: Box<dyn std::error::Error + Send + Sync + 'static>,
81 },
82 #[error("failed to remove issue {id} from cache : {err}")]
83 CacheRemove {
84 id: IssueId,
85 #[source]
86 err: Box<dyn std::error::Error + Send + Sync + 'static>,
87 },
88 #[error("failed to remove issues from cache: {err}")]
89 CacheRemoveAll {
90 #[source]
91 err: Box<dyn std::error::Error + Send + Sync + 'static>,
92 },
93}
94
95#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub enum CloseReason {
99 Other,
100 Solved,
101}
102
103impl std::fmt::Display for CloseReason {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 let reason = match self {
106 Self::Other => "unspecified",
107 Self::Solved => "solved",
108 };
109 write!(f, "{reason}")
110 }
111}
112
113#[derive(Debug, Default, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase", tag = "status")]
116pub enum State {
117 Closed { reason: CloseReason },
119 #[default]
121 Open,
122}
123
124impl std::fmt::Display for State {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 match self {
127 Self::Closed { .. } => write!(f, "closed"),
128 Self::Open => write!(f, "open"),
129 }
130 }
131}
132
133impl State {
134 pub fn lifecycle_message(self) -> String {
135 match self {
136 Self::Open => "Open issue".to_owned(),
137 Self::Closed { .. } => "Close issue".to_owned(),
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct Issue {
146 pub(super) assignees: BTreeSet<Did>,
148 pub(super) title: String,
150 pub(super) state: State,
152 pub(super) labels: BTreeSet<Label>,
154 pub(super) thread: Thread,
156}
157
158impl cob::store::CobWithType for Issue {
159 fn type_name() -> &'static TypeName {
160 &TYPENAME
161 }
162}
163
164impl store::Cob for Issue {
165 type Action = Action;
166 type Error = Error;
167
168 fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
169 let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
170 let mut actions = op.actions.into_iter();
171 let Some(Action::Comment {
172 body,
173 reply_to: None,
174 embeds,
175 }) = actions.next()
176 else {
177 return Err(Error::Init("the first action must be of type `comment`"));
178 };
179 let comment = Comment::new(op.author, body, None, None, embeds, op.timestamp);
180 let thread = Thread::new(op.id, comment);
181 let mut issue = Issue::new(thread);
182
183 for action in actions {
184 match issue.authorization(&action, &op.author, &doc)? {
185 Authorization::Allow => {
186 issue.action(action, op.id, op.author, op.timestamp, &[], &doc, repo)?;
187 }
188 Authorization::Deny => {
189 return Err(Error::NotAuthorized(op.author, action));
190 }
191 Authorization::Unknown => {
192 continue;
195 }
196 }
197 }
198 Ok(issue)
199 }
200
201 fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a cob::Entry>>(
202 &mut self,
203 op: Op,
204 concurrent: I,
205 repo: &R,
206 ) -> Result<(), Error> {
207 let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
208 let concurrent = concurrent.into_iter().collect::<Vec<_>>();
209
210 for action in op.actions {
211 log::trace!(target: "issue", "Applying {} {action:?}", op.id);
212
213 if let Err(e) = self.op_action(
214 action,
215 op.id,
216 op.author,
217 op.timestamp,
218 &concurrent,
219 &doc,
220 repo,
221 ) {
222 log::error!(target: "issue", "Error applying {}: {e}", op.id);
223 return Err(e);
224 }
225 }
226 Ok(())
227 }
228}
229
230impl<R: ReadRepository> cob::Evaluate<R> for Issue {
231 type Error = Error;
232
233 fn init(entry: &cob::Entry, repo: &R) -> Result<Self, Self::Error> {
234 let op = Op::try_from(entry)?;
235 let object = Issue::from_root(op, repo)?;
236
237 Ok(object)
238 }
239
240 fn apply<'a, I: Iterator<Item = (&'a EntryId, &'a cob::Entry)>>(
241 &mut self,
242 entry: &cob::Entry,
243 concurrent: I,
244 repo: &R,
245 ) -> Result<(), Self::Error> {
246 let op = Op::try_from(entry)?;
247
248 self.op(op, concurrent.map(|(_, e)| e), repo)
249 }
250}
251
252impl Issue {
253 pub fn new(thread: Thread) -> Self {
255 Self {
256 assignees: BTreeSet::default(),
257 title: String::default(),
258 state: State::default(),
259 labels: BTreeSet::default(),
260 thread,
261 }
262 }
263
264 pub fn assignees(&self) -> impl Iterator<Item = &Did> + '_ {
265 self.assignees.iter()
266 }
267
268 pub fn title(&self) -> &str {
269 self.title.as_str()
270 }
271
272 pub fn state(&self) -> &State {
273 &self.state
274 }
275
276 pub fn labels(&self) -> impl Iterator<Item = &Label> {
277 self.labels.iter()
278 }
279
280 pub fn timestamp(&self) -> Timestamp {
281 self.thread
282 .comments()
283 .next()
284 .map(|(_, c)| c)
285 .expect("Issue::timestamp: at least one comment is present")
286 .timestamp()
287 }
288
289 pub fn author(&self) -> Author {
290 self.thread
291 .comments()
292 .next()
293 .map(|(_, c)| Author::new(c.author()))
294 .expect("Issue::author: at least one comment is present")
295 }
296
297 pub fn root(&self) -> (&CommentId, &Comment) {
298 self.thread
299 .comments()
300 .next()
301 .expect("Issue::root: at least one comment is present")
302 }
303
304 pub fn description(&self) -> &str {
305 self.thread
306 .comments()
307 .next()
308 .map(|(_, c)| c.body())
309 .expect("Issue::description: at least one comment is present")
310 }
311
312 pub fn thread(&self) -> &Thread {
313 &self.thread
314 }
315
316 pub fn comments(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment)> {
317 self.thread.comments()
318 }
319
320 pub fn replies_to<'a>(
322 &'a self,
323 to: &'a CommentId,
324 ) -> impl Iterator<Item = (&'a CommentId, &'a thread::Comment)> {
325 self.thread.replies(to)
326 }
327
328 pub fn replies(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment)> {
331 self.comments().skip(1)
332 }
333
334 pub fn authorization(
336 &self,
337 action: &Action,
338 actor: &ActorId,
339 doc: &Doc,
340 ) -> Result<Authorization, Error> {
341 if doc.is_delegate(&actor.into()) {
342 return Ok(Authorization::Allow);
344 }
345 let author: ActorId = *self.author().id().as_key();
346 let outcome = match action {
347 Action::Assign { assignees } => {
349 if assignees == &self.assignees {
350 Authorization::Allow
352 } else {
353 Authorization::Deny
354 }
355 }
356 Action::Edit { .. } => Authorization::from(*actor == author),
358 Action::Lifecycle { state } => Authorization::from(match state {
360 State::Closed { .. } => *actor == author,
361 State::Open => *actor == author,
362 }),
363 Action::Label { labels } => {
365 if labels == &self.labels {
366 Authorization::Allow
368 } else {
369 Authorization::Deny
370 }
371 }
372 Action::Comment { .. } => Authorization::Allow,
374 Action::CommentEdit { id, .. } | Action::CommentRedact { id, .. } => {
376 if let Some(comment) = self.thread.comments.get(id) {
377 if let Some(comment) = comment {
378 Authorization::from(*actor == comment.author())
379 } else {
380 Authorization::Unknown
381 }
382 } else {
383 return Err(Error::Thread(thread::Error::Missing(*id)));
384 }
385 }
386 Action::CommentReact { .. } => Authorization::Allow,
388 };
389 Ok(outcome)
390 }
391}
392
393impl Issue {
394 fn op_action<R: ReadRepository>(
395 &mut self,
396 action: Action,
397 id: EntryId,
398 author: ActorId,
399 timestamp: Timestamp,
400 concurrent: &[&cob::Entry],
401 doc: &Doc,
402 repo: &R,
403 ) -> Result<(), Error> {
404 match self.authorization(&action, &author, doc)? {
405 Authorization::Allow => {
406 self.action(action, id, author, timestamp, concurrent, doc, repo)
407 }
408 Authorization::Deny => Err(Error::NotAuthorized(author, action)),
409 Authorization::Unknown => Ok(()),
410 }
411 }
412
413 fn action<R: ReadRepository>(
415 &mut self,
416 action: Action,
417 entry: EntryId,
418 author: ActorId,
419 timestamp: Timestamp,
420 _concurrent: &[&cob::Entry],
421 _doc: &Doc,
422 _repo: &R,
423 ) -> Result<(), Error> {
424 match action {
425 Action::Assign { assignees } => {
426 self.assignees = BTreeSet::from_iter(assignees);
427 }
428 Action::Edit { title } => {
429 self.title = title.to_string();
430 }
431 Action::Lifecycle { state } => {
432 self.state = state;
433 }
434 Action::Label { labels } => {
435 self.labels = BTreeSet::from_iter(labels);
436 }
437 Action::Comment {
438 body,
439 reply_to,
440 embeds,
441 } => {
442 thread::comment(
443 &mut self.thread,
444 entry,
445 author,
446 timestamp,
447 body,
448 reply_to,
449 None,
450 embeds,
451 )?;
452 }
453 Action::CommentEdit { id, body, embeds } => {
454 thread::edit(&mut self.thread, entry, author, id, timestamp, body, embeds)?;
455 }
456 Action::CommentRedact { id } => {
457 let (root, _) = self.root();
458 if id == *root {
459 return Err(Error::NotAllowed(entry));
460 }
461 thread::redact(&mut self.thread, entry, id)?;
462 }
463 Action::CommentReact {
464 id,
465 reaction,
466 active,
467 } => {
468 thread::react(&mut self.thread, entry, author, id, reaction, active)?;
469 }
470 }
471 Ok(())
472 }
473}
474
475impl<'a, 'g, R, C> From<IssueMut<'a, 'g, R, C>> for (IssueId, Issue) {
476 fn from(value: IssueMut<'a, 'g, R, C>) -> Self {
477 (value.id, value.issue)
478 }
479}
480
481impl Deref for Issue {
482 type Target = Thread;
483
484 fn deref(&self) -> &Self::Target {
485 &self.thread
486 }
487}
488
489impl<R: ReadRepository> store::Transaction<Issue, R> {
490 pub fn assign(&mut self, assignees: impl IntoIterator<Item = Did>) -> Result<(), store::Error> {
492 self.push(Action::Assign {
493 assignees: assignees.into_iter().collect(),
494 })
495 }
496
497 pub fn edit_comment(
499 &mut self,
500 id: CommentId,
501 body: impl ToString,
502 embeds: Vec<Embed<Uri>>,
503 ) -> Result<(), store::Error> {
504 self.embed(embeds.clone())?;
505 self.push(Action::CommentEdit {
506 id,
507 body: body.to_string(),
508 embeds,
509 })
510 }
511
512 pub fn edit(&mut self, title: cob::Title) -> Result<(), store::Error> {
514 self.push(Action::Edit { title })
515 }
516
517 pub fn redact_comment(&mut self, id: CommentId) -> Result<(), store::Error> {
519 self.push(Action::CommentRedact { id })
520 }
521
522 pub fn lifecycle(&mut self, state: State) -> Result<(), store::Error> {
524 self.push(Action::Lifecycle { state })
525 }
526
527 pub fn comment<S: ToString>(
529 &mut self,
530 body: S,
531 reply_to: CommentId,
532 embeds: Vec<Embed<Uri>>,
533 ) -> Result<(), store::Error> {
534 self.embed(embeds.clone())?;
535 self.push(Action::Comment {
536 body: body.to_string(),
537 reply_to: Some(reply_to),
538 embeds,
539 })
540 }
541
542 pub fn label(&mut self, labels: impl IntoIterator<Item = Label>) -> Result<(), store::Error> {
544 self.push(Action::Label {
545 labels: labels.into_iter().collect(),
546 })
547 }
548
549 pub fn react(
551 &mut self,
552 id: CommentId,
553 reaction: Reaction,
554 active: bool,
555 ) -> Result<(), store::Error> {
556 self.push(Action::CommentReact {
557 id,
558 reaction,
559 active,
560 })
561 }
562
563 fn thread<S: ToString>(
567 &mut self,
568 body: S,
569 embeds: impl IntoIterator<Item = Embed<Uri>>,
570 ) -> Result<(), store::Error> {
571 let embeds = embeds.into_iter().collect::<Vec<_>>();
572
573 self.embed(embeds.clone())?;
574 self.push(Action::Comment {
575 body: body.to_string(),
576 reply_to: None,
577 embeds,
578 })
579 }
580}
581
582pub struct IssueMut<'a, 'g, R, C> {
583 id: ObjectId,
584 issue: Issue,
585 store: &'g mut Issues<'a, R>,
586 cache: &'g mut C,
587}
588
589impl<R, C> std::fmt::Debug for IssueMut<'_, '_, R, C> {
590 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
591 f.debug_struct("IssueMut")
592 .field("id", &self.id)
593 .field("issue", &self.issue)
594 .finish()
595 }
596}
597
598impl<R, C> IssueMut<'_, '_, R, C>
599where
600 R: WriteRepository + cob::Store<Namespace = NodeId>,
601 C: cob::cache::Update<Issue>,
602{
603 pub fn reload(&mut self) -> Result<(), store::Error> {
605 self.issue = self
606 .store
607 .get(&self.id)?
608 .ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?;
609
610 Ok(())
611 }
612
613 pub fn id(&self) -> &ObjectId {
615 &self.id
616 }
617
618 pub fn assign<G>(
620 &mut self,
621 assignees: impl IntoIterator<Item = Did>,
622 signer: &Device<G>,
623 ) -> Result<EntryId, Error>
624 where
625 G: crypto::signature::Signer<crypto::Signature>,
626 {
627 self.transaction("Assign", signer, |tx| tx.assign(assignees))
628 }
629
630 pub fn edit<G>(&mut self, title: cob::Title, signer: &Device<G>) -> Result<EntryId, Error>
632 where
633 G: crypto::signature::Signer<crypto::Signature>,
634 {
635 self.transaction("Edit", signer, |tx| tx.edit(title))
636 }
637
638 pub fn edit_description<G>(
640 &mut self,
641 description: impl ToString,
642 embeds: impl IntoIterator<Item = Embed<Uri>>,
643 signer: &Device<G>,
644 ) -> Result<EntryId, Error>
645 where
646 G: crypto::signature::Signer<crypto::Signature>,
647 {
648 let (id, _) = self.root();
649 let id = *id;
650 self.transaction("Edit description", signer, |tx| {
651 tx.edit_comment(id, description, embeds.into_iter().collect())
652 })
653 }
654
655 pub fn lifecycle<G>(&mut self, state: State, signer: &Device<G>) -> Result<EntryId, Error>
657 where
658 G: crypto::signature::Signer<crypto::Signature>,
659 {
660 self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state))
661 }
662
663 pub fn comment<G, S>(
665 &mut self,
666 body: S,
667 reply_to: CommentId,
668 embeds: impl IntoIterator<Item = Embed<Uri>>,
669 signer: &Device<G>,
670 ) -> Result<EntryId, Error>
671 where
672 G: crypto::signature::Signer<crypto::Signature>,
673 S: ToString,
674 {
675 self.transaction("Comment", signer, |tx| {
676 tx.comment(body, reply_to, embeds.into_iter().collect())
677 })
678 }
679
680 pub fn edit_comment<G, S>(
682 &mut self,
683 id: CommentId,
684 body: S,
685 embeds: impl IntoIterator<Item = Embed<Uri>>,
686 signer: &Device<G>,
687 ) -> Result<EntryId, Error>
688 where
689 G: crypto::signature::Signer<crypto::Signature>,
690 S: ToString,
691 {
692 self.transaction("Edit comment", signer, |tx| {
693 tx.edit_comment(id, body, embeds.into_iter().collect())
694 })
695 }
696
697 pub fn redact_comment<G>(&mut self, id: CommentId, signer: &Device<G>) -> Result<EntryId, Error>
699 where
700 G: crypto::signature::Signer<crypto::Signature>,
701 {
702 self.transaction("Redact comment", signer, |tx| tx.redact_comment(id))
703 }
704
705 pub fn label<G>(
707 &mut self,
708 labels: impl IntoIterator<Item = Label>,
709 signer: &Device<G>,
710 ) -> Result<EntryId, Error>
711 where
712 G: crypto::signature::Signer<crypto::Signature>,
713 {
714 self.transaction("Label", signer, |tx| tx.label(labels))
715 }
716
717 pub fn react<G>(
719 &mut self,
720 to: CommentId,
721 reaction: Reaction,
722 active: bool,
723 signer: &Device<G>,
724 ) -> Result<EntryId, Error>
725 where
726 G: crypto::signature::Signer<crypto::Signature>,
727 {
728 self.transaction("React", signer, |tx| tx.react(to, reaction, active))
729 }
730
731 pub fn transaction<G, F>(
732 &mut self,
733 message: &str,
734 signer: &Device<G>,
735 operations: F,
736 ) -> Result<EntryId, Error>
737 where
738 G: crypto::signature::Signer<crypto::Signature>,
739 F: FnOnce(&mut Transaction<Issue, R>) -> Result<(), store::Error>,
740 {
741 let mut tx = Transaction::default();
742 operations(&mut tx)?;
743
744 let (issue, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
745 self.cache
746 .update(&self.store.as_ref().id(), &self.id, &issue)
747 .map_err(|e| Error::CacheUpdate {
748 id: self.id,
749 err: e.into(),
750 })?;
751 self.issue = issue;
752
753 Ok(commit)
754 }
755}
756
757impl<R, C> Deref for IssueMut<'_, '_, R, C> {
758 type Target = Issue;
759
760 fn deref(&self) -> &Self::Target {
761 &self.issue
762 }
763}
764
765pub struct Issues<'a, R> {
766 raw: store::Store<'a, Issue, R>,
767}
768
769impl<'a, R> Deref for Issues<'a, R> {
770 type Target = store::Store<'a, Issue, R>;
771
772 fn deref(&self) -> &Self::Target {
773 &self.raw
774 }
775}
776
777impl<R> HasRepoId for Issues<'_, R>
778where
779 R: ReadRepository,
780{
781 fn rid(&self) -> RepoId {
782 self.raw.as_ref().id()
783 }
784}
785
786#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize)]
788#[serde(rename_all = "camelCase")]
789pub struct IssueCounts {
790 pub open: usize,
791 pub closed: usize,
792}
793
794impl IssueCounts {
795 pub fn total(&self) -> usize {
797 self.open + self.closed
798 }
799}
800
801impl<'a, R> Issues<'a, R>
802where
803 R: ReadRepository + cob::Store<Namespace = NodeId>,
804{
805 pub fn open(repository: &'a R) -> Result<Self, RepositoryError> {
807 let identity = repository.identity_head()?;
808 let raw = store::Store::open(repository)?.identity(identity);
809
810 Ok(Self { raw })
811 }
812}
813
814impl<'a, R> Issues<'a, R>
815where
816 R: WriteRepository + cob::Store<Namespace = NodeId>,
817{
818 pub fn create<'g, G, C>(
820 &'g mut self,
821 title: cob::Title,
822 description: impl ToString,
823 labels: &[Label],
824 assignees: &[Did],
825 embeds: impl IntoIterator<Item = Embed<Uri>>,
826 cache: &'g mut C,
827 signer: &Device<G>,
828 ) -> Result<IssueMut<'a, 'g, R, C>, Error>
829 where
830 G: crypto::signature::Signer<crypto::Signature>,
831 C: cob::cache::Update<Issue>,
832 {
833 let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx, _| {
834 tx.thread(description, embeds)?;
835 tx.edit(title)?;
836
837 if !assignees.is_empty() {
838 tx.assign(assignees.to_owned())?;
839 }
840 if !labels.is_empty() {
841 tx.label(labels.to_owned())?;
842 }
843 Ok(())
844 })?;
845 cache
846 .update(&self.raw.as_ref().id(), &id, &issue)
847 .map_err(|e| Error::CacheUpdate { id, err: e.into() })?;
848
849 Ok(IssueMut {
850 id,
851 issue,
852 store: self,
853 cache,
854 })
855 }
856
857 pub fn remove<C, G>(&self, id: &ObjectId, signer: &Device<G>) -> Result<(), store::Error>
859 where
860 C: cob::cache::Remove<Issue>,
861 G: crypto::signature::Signer<crypto::Signature>,
862 {
863 self.raw.remove(id, signer)
864 }
865}
866
867impl<'a, R> Issues<'a, R>
868where
869 R: ReadRepository + cob::Store,
870{
871 pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, store::Error> {
873 self.raw.get(id)
874 }
875
876 pub fn get_mut<'g, C>(
878 &'g mut self,
879 id: &ObjectId,
880 cache: &'g mut C,
881 ) -> Result<IssueMut<'a, 'g, R, C>, store::Error> {
882 let issue = self
883 .raw
884 .get(id)?
885 .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
886
887 Ok(IssueMut {
888 id: *id,
889 issue,
890 store: self,
891 cache,
892 })
893 }
894
895 pub fn counts(&self) -> Result<IssueCounts, Error> {
897 let all = self.all()?;
898 let state_groups =
899 all.filter_map(|s| s.ok())
900 .fold(IssueCounts::default(), |mut state, (_, p)| {
901 match p.state() {
902 State::Open => state.open += 1,
903 State::Closed { .. } => state.closed += 1,
904 }
905 state
906 });
907
908 Ok(state_groups)
909 }
910}
911
912#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
914#[serde(tag = "type", rename_all = "camelCase")]
915pub enum Action {
916 #[serde(rename = "assign")]
918 Assign { assignees: BTreeSet<Did> },
919
920 #[serde(rename = "edit")]
922 Edit { title: cob::Title },
923
924 #[serde(rename = "lifecycle")]
926 Lifecycle { state: State },
927
928 #[serde(rename = "label")]
930 Label { labels: BTreeSet<Label> },
931
932 #[serde(rename_all = "camelCase")]
934 #[serde(rename = "comment")]
935 Comment {
936 body: String,
938 #[serde(default, skip_serializing_if = "Option::is_none")]
942 reply_to: Option<CommentId>,
943 #[serde(default, skip_serializing_if = "Vec::is_empty")]
945 embeds: Vec<Embed<Uri>>,
946 },
947
948 #[serde(rename = "comment.edit")]
950 CommentEdit {
951 id: CommentId,
953 body: String,
955 embeds: Vec<Embed<Uri>>,
957 },
958
959 #[serde(rename = "comment.redact")]
961 CommentRedact { id: CommentId },
962
963 #[serde(rename = "comment.react")]
965 CommentReact {
966 id: CommentId,
967 reaction: Reaction,
968 active: bool,
969 },
970}
971
972impl CobAction for Action {
973 fn produces_identifier(&self) -> bool {
974 matches!(self, Self::Comment { .. })
975 }
976}
977
978#[cfg(test)]
979#[allow(clippy::unwrap_used)]
980mod test {
981 use pretty_assertions::assert_eq;
982
983 use super::*;
984 use crate::cob::{store::CobWithType, ActorId, Reaction};
985 use crate::git::Oid;
986 use crate::issue::cache::Issues as _;
987 use crate::test::arbitrary;
988 use crate::{assert_matches, test};
989
990 #[test]
991 fn test_concurrency() {
992 let t = test::setup::Network::default();
993 let mut issues_alice = Cache::no_cache(&*t.alice.repo).unwrap();
994 let mut bob_issues = Cache::no_cache(&*t.bob.repo).unwrap();
995 let mut eve_issues = Cache::no_cache(&*t.eve.repo).unwrap();
996 let mut issue_alice = issues_alice
997 .create(
998 cob::Title::new("Alice Issue").unwrap(),
999 "Alice's comment",
1000 &[],
1001 &[],
1002 [],
1003 &t.alice.signer,
1004 )
1005 .unwrap();
1006 let id = *issue_alice.id();
1007
1008 t.bob.repo.fetch(&t.alice);
1009 t.eve.repo.fetch(&t.alice);
1010
1011 let mut issue_eve = eve_issues.get_mut(&id).unwrap();
1012 let mut issue_bob = bob_issues.get_mut(&id).unwrap();
1013
1014 issue_bob
1015 .comment("Bob's reply", *id, vec![], &t.bob.signer)
1016 .unwrap();
1017 issue_alice
1018 .comment("Alice's reply", *id, vec![], &t.alice.signer)
1019 .unwrap();
1020
1021 assert_eq!(issue_bob.comments().count(), 2);
1022 assert_eq!(issue_alice.comments().count(), 2);
1023
1024 t.bob.repo.fetch(&t.alice);
1025 issue_bob.reload().unwrap();
1026 assert_eq!(issue_bob.comments().count(), 3);
1027
1028 t.alice.repo.fetch(&t.bob);
1029 issue_alice.reload().unwrap();
1030 assert_eq!(issue_alice.comments().count(), 3);
1031
1032 let bob_comments = issue_bob
1033 .comments()
1034 .map(|(_, c)| c.body())
1035 .collect::<Vec<_>>();
1036 let alice_comments = issue_alice
1037 .comments()
1038 .map(|(_, c)| c.body())
1039 .collect::<Vec<_>>();
1040
1041 assert_eq!(bob_comments, alice_comments);
1042
1043 t.eve.repo.fetch(&t.alice);
1044
1045 let eve_reply = issue_eve
1046 .comment("Eve's reply", *id, vec![], &t.eve.signer)
1047 .unwrap();
1048
1049 t.bob.repo.fetch(&t.eve);
1050 t.alice.repo.fetch(&t.eve);
1051
1052 issue_alice.reload().unwrap();
1053 issue_bob.reload().unwrap();
1054 issue_eve.reload().unwrap();
1055
1056 assert_eq!(issue_eve.comments().count(), 4);
1057 assert_eq!(issue_bob.comments().count(), 4);
1058 assert_eq!(issue_alice.comments().count(), 4);
1059
1060 let (first, _) = issue_bob.comments().next().unwrap();
1061 let (last, _) = issue_bob.comments().last().unwrap();
1062
1063 assert_eq!(*first, *issue_alice.id);
1064 assert_eq!(*last, eve_reply);
1065 }
1066
1067 #[test]
1068 fn test_ordering() {
1069 assert!(CloseReason::Solved > CloseReason::Other);
1070 assert!(
1071 State::Open
1072 > State::Closed {
1073 reason: CloseReason::Solved
1074 }
1075 );
1076 }
1077
1078 #[test]
1079 fn test_issue_create_and_assign() {
1080 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1081 let mut issues = Cache::no_cache(&*repo).unwrap();
1082
1083 let assignee = Did::from(arbitrary::gen::<ActorId>(1));
1084 let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
1085 let issue = issues
1086 .create(
1087 cob::Title::new("My first issue").unwrap(),
1088 "Blah blah blah.",
1089 &[],
1090 &[assignee],
1091 [],
1092 &node.signer,
1093 )
1094 .unwrap();
1095
1096 let id = issue.id;
1097 let issue = issues.get(&id).unwrap().unwrap();
1098 let assignees: Vec<_> = issue.assignees().cloned().collect::<Vec<_>>();
1099
1100 assert_eq!(1, assignees.len());
1101 assert!(assignees.contains(&assignee));
1102
1103 let mut issue = issues.get_mut(&id).unwrap();
1104 issue
1105 .assign([assignee, assignee_two], &node.signer)
1106 .unwrap();
1107
1108 let id = issue.id;
1109 let issue = issues.get(&id).unwrap().unwrap();
1110 let assignees: Vec<_> = issue.assignees().cloned().collect::<Vec<_>>();
1111
1112 assert_eq!(2, assignees.len());
1113 assert!(assignees.contains(&assignee));
1114 assert!(assignees.contains(&assignee_two));
1115 }
1116
1117 #[test]
1118 fn test_issue_create_and_reassign() {
1119 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1120 let mut issues = Cache::no_cache(&*repo).unwrap();
1121
1122 let assignee = Did::from(arbitrary::gen::<ActorId>(1));
1123 let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
1124 let mut issue = issues
1125 .create(
1126 cob::Title::new("My first issue").unwrap(),
1127 "Blah blah blah.",
1128 &[],
1129 &[assignee, assignee_two],
1130 [],
1131 &node.signer,
1132 )
1133 .unwrap();
1134
1135 issue.assign([assignee_two], &node.signer).unwrap();
1136 issue.assign([assignee_two], &node.signer).unwrap();
1137 issue.reload().unwrap();
1138
1139 let assignees: Vec<_> = issue.assignees().cloned().collect::<Vec<_>>();
1140
1141 assert_eq!(1, assignees.len());
1142 assert!(assignees.contains(&assignee_two));
1143
1144 issue.assign([], &node.signer).unwrap();
1145 issue.reload().unwrap();
1146
1147 assert_eq!(0, issue.assignees().count());
1148 }
1149
1150 #[test]
1151 fn test_issue_create_and_get() {
1152 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1153 let mut issues = Cache::no_cache(&*repo).unwrap();
1154 let created = issues
1155 .create(
1156 cob::Title::new("My first issue").unwrap(),
1157 "Blah blah blah.",
1158 &[],
1159 &[],
1160 [],
1161 &node.signer,
1162 )
1163 .unwrap();
1164
1165 let (id, created) = (created.id, created.issue);
1166 let issue = issues.get(&id).unwrap().unwrap();
1167
1168 assert_eq!(created, issue);
1169 assert_eq!(issue.title(), "My first issue");
1170 assert_eq!(issue.author().id, Did::from(node.signer.public_key()));
1171 assert_eq!(issue.description(), "Blah blah blah.");
1172 assert_eq!(issue.comments().count(), 1);
1173 assert_eq!(issue.state(), &State::Open);
1174 }
1175
1176 #[test]
1177 fn test_issue_create_and_change_state() {
1178 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1179 let mut issues = Cache::no_cache(&*repo).unwrap();
1180 let mut issue = issues
1181 .create(
1182 cob::Title::new("My first issue").unwrap(),
1183 "Blah blah blah.",
1184 &[],
1185 &[],
1186 [],
1187 &node.signer,
1188 )
1189 .unwrap();
1190
1191 issue
1192 .lifecycle(
1193 State::Closed {
1194 reason: CloseReason::Other,
1195 },
1196 &node.signer,
1197 )
1198 .unwrap();
1199
1200 let id = issue.id;
1201 let mut issue = issues.get_mut(&id).unwrap();
1202
1203 assert_eq!(
1204 *issue.state(),
1205 State::Closed {
1206 reason: CloseReason::Other
1207 }
1208 );
1209
1210 issue.lifecycle(State::Open, &node.signer).unwrap();
1211 let issue = issues.get(&id).unwrap().unwrap();
1212
1213 assert_eq!(*issue.state(), State::Open);
1214 }
1215
1216 #[test]
1217 fn test_issue_create_and_unassign() {
1218 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1219 let mut issues = Cache::no_cache(&*repo).unwrap();
1220
1221 let assignee = Did::from(arbitrary::gen::<ActorId>(1));
1222 let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
1223 let mut issue = issues
1224 .create(
1225 cob::Title::new("My first issue").unwrap(),
1226 "Blah blah blah.",
1227 &[],
1228 &[assignee, assignee_two],
1229 [],
1230 &node.signer,
1231 )
1232 .unwrap();
1233 assert_eq!(2, issue.assignees().count());
1234
1235 issue.assign([assignee_two], &node.signer).unwrap();
1236 issue.reload().unwrap();
1237
1238 let assignees: Vec<_> = issue.assignees().cloned().collect::<Vec<_>>();
1239
1240 assert_eq!(1, assignees.len());
1241 assert!(assignees.contains(&assignee_two));
1242 }
1243
1244 #[test]
1245 fn test_issue_edit() {
1246 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1247 let mut issues = Cache::no_cache(&*repo).unwrap();
1248
1249 let mut issue = issues
1250 .create(
1251 cob::Title::new("My first issue").unwrap(),
1252 "Blah blah blah.",
1253 &[],
1254 &[],
1255 [],
1256 &node.signer,
1257 )
1258 .unwrap();
1259
1260 issue
1261 .edit(cob::Title::new("Sorry typo").unwrap(), &node.signer)
1262 .unwrap();
1263
1264 let id = issue.id;
1265 let issue = issues.get(&id).unwrap().unwrap();
1266 let r = issue.title();
1267
1268 assert_eq!(r, "Sorry typo");
1269 }
1270
1271 #[test]
1272 fn test_issue_edit_description() {
1273 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1274 let mut issues = Cache::no_cache(&*repo).unwrap();
1275 let mut issue = issues
1276 .create(
1277 cob::Title::new("My first issue").unwrap(),
1278 "Blah blah blah.",
1279 &[],
1280 &[],
1281 [],
1282 &node.signer,
1283 )
1284 .unwrap();
1285
1286 issue
1287 .edit_description("Bob Loblaw law blog", vec![], &node.signer)
1288 .unwrap();
1289
1290 let id = issue.id;
1291 let issue = issues.get(&id).unwrap().unwrap();
1292 let desc = issue.description();
1293
1294 assert_eq!(desc, "Bob Loblaw law blog");
1295 }
1296
1297 #[test]
1298 fn test_issue_react() {
1299 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1300 let mut issues = Cache::no_cache(&*repo).unwrap();
1301 let mut issue = issues
1302 .create(
1303 cob::Title::new("My first issue").unwrap(),
1304 "Blah blah blah.",
1305 &[],
1306 &[],
1307 [],
1308 &node.signer,
1309 )
1310 .unwrap();
1311
1312 let (comment, _) = issue.root();
1313 let comment = *comment;
1314 let reaction = Reaction::new('🥳').unwrap();
1315 issue.react(comment, reaction, true, &node.signer).unwrap();
1316
1317 let id = issue.id;
1318 let issue = issues.get(&id).unwrap().unwrap();
1319 let reactions = issue.comment(&comment).unwrap().reactions();
1320 let authors = reactions.get(&reaction).unwrap();
1321
1322 assert_eq!(authors.first().unwrap(), &node.signer.public_key());
1323
1324 }
1326
1327 #[test]
1328 fn test_issue_reply() {
1329 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1330 let mut issues = Cache::no_cache(&*repo).unwrap();
1331 let mut issue = issues
1332 .create(
1333 cob::Title::new("My first issue").unwrap(),
1334 "Blah blah blah.",
1335 &[],
1336 &[],
1337 [],
1338 &node.signer,
1339 )
1340 .unwrap();
1341 let (root, _) = issue.root();
1342 let root = *root;
1343
1344 let c1 = issue
1345 .comment("Hi hi hi.", root, vec![], &node.signer)
1346 .unwrap();
1347 let c2 = issue
1348 .comment("Ha ha ha.", root, vec![], &node.signer)
1349 .unwrap();
1350
1351 let id = issue.id;
1352 let mut issue = issues.get_mut(&id).unwrap();
1353 let (_, reply1) = &issue.replies_to(&root).nth(0).unwrap();
1354 let (_, reply2) = &issue.replies_to(&root).nth(1).unwrap();
1355
1356 assert_eq!(reply1.body(), "Hi hi hi.");
1357 assert_eq!(reply2.body(), "Ha ha ha.");
1358
1359 issue.comment("Re: Hi.", c1, vec![], &node.signer).unwrap();
1360 issue.comment("Re: Ha.", c2, vec![], &node.signer).unwrap();
1361 issue
1362 .comment("Re: Ha. Ha.", c2, vec![], &node.signer)
1363 .unwrap();
1364 issue
1365 .comment("Re: Ha. Ha. Ha.", c2, vec![], &node.signer)
1366 .unwrap();
1367
1368 let issue = issues.get(&id).unwrap().unwrap();
1369
1370 assert_eq!(issue.replies_to(&c1).nth(0).unwrap().1.body(), "Re: Hi.");
1371 assert_eq!(issue.replies_to(&c2).nth(0).unwrap().1.body(), "Re: Ha.");
1372 assert_eq!(
1373 issue.replies_to(&c2).nth(1).unwrap().1.body(),
1374 "Re: Ha. Ha."
1375 );
1376 assert_eq!(
1377 issue.replies_to(&c2).nth(2).unwrap().1.body(),
1378 "Re: Ha. Ha. Ha."
1379 );
1380 }
1381
1382 #[test]
1383 fn test_issue_label() {
1384 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1385 let mut issues = Cache::no_cache(&*repo).unwrap();
1386 let bug_label = Label::new("bug").unwrap();
1387 let ux_label = Label::new("ux").unwrap();
1388 let wontfix_label = Label::new("wontfix").unwrap();
1389 let mut issue = issues
1390 .create(
1391 cob::Title::new("My first issue").unwrap(),
1392 "Blah blah blah.",
1393 &[ux_label.clone()],
1394 &[],
1395 [],
1396 &node.signer,
1397 )
1398 .unwrap();
1399
1400 issue
1401 .label([ux_label.clone(), bug_label.clone()], &node.signer)
1402 .unwrap();
1403 issue
1404 .label(
1405 [ux_label.clone(), bug_label.clone(), wontfix_label.clone()],
1406 &node.signer,
1407 )
1408 .unwrap();
1409
1410 let id = issue.id;
1411 let issue = issues.get(&id).unwrap().unwrap();
1412 let labels = issue.labels().cloned().collect::<Vec<_>>();
1413
1414 assert!(labels.contains(&ux_label));
1415 assert!(labels.contains(&bug_label));
1416 assert!(labels.contains(&wontfix_label));
1417 }
1418
1419 #[test]
1420 fn test_issue_comment() {
1421 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1422 let author = *node.signer.public_key();
1423 let mut issues = Cache::no_cache(&*repo).unwrap();
1424 let mut issue = issues
1425 .create(
1426 cob::Title::new("My first issue").unwrap(),
1427 "Blah blah blah.",
1428 &[],
1429 &[],
1430 [],
1431 &node.signer,
1432 )
1433 .unwrap();
1434
1435 let (c0, _) = issue.root();
1437 let c0 = *c0;
1438
1439 issue
1440 .comment("Ho ho ho.", c0, vec![], &node.signer)
1441 .unwrap();
1442 issue
1443 .comment("Ha ha ha.", c0, vec![], &node.signer)
1444 .unwrap();
1445
1446 let id = issue.id;
1447 let issue = issues.get(&id).unwrap().unwrap();
1448 let (_, c0) = &issue.comments().nth(0).unwrap();
1449 let (_, c1) = &issue.comments().nth(1).unwrap();
1450 let (_, c2) = &issue.comments().nth(2).unwrap();
1451
1452 assert_eq!(c0.body(), "Blah blah blah.");
1453 assert_eq!(c0.author(), author);
1454 assert_eq!(c1.body(), "Ho ho ho.");
1455 assert_eq!(c1.author(), author);
1456 assert_eq!(c2.body(), "Ha ha ha.");
1457 assert_eq!(c2.author(), author);
1458 }
1459
1460 #[test]
1461 fn test_issue_comment_redact() {
1462 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1463 let mut issues = Cache::no_cache(&*repo).unwrap();
1464 let mut issue = issues
1465 .create(
1466 cob::Title::new("My first issue").unwrap(),
1467 "Blah blah blah.",
1468 &[],
1469 &[],
1470 [],
1471 &node.signer,
1472 )
1473 .unwrap();
1474
1475 let (c0, _) = issue.root();
1477 let c0 = *c0;
1478
1479 let comment = issue
1480 .comment("Ho ho ho.", c0, vec![], &node.signer)
1481 .unwrap();
1482 issue.reload().unwrap();
1483 assert_eq!(issue.comments().count(), 2);
1484
1485 issue.redact_comment(comment, &node.signer).unwrap();
1486 assert_eq!(issue.comments().count(), 1);
1487
1488 issue.redact_comment(*issue.id, &node.signer).unwrap_err();
1490 }
1491
1492 #[test]
1493 fn test_issue_state_serde() {
1494 assert_eq!(
1495 serde_json::to_value(State::Open).unwrap(),
1496 serde_json::json!({ "status": "open" })
1497 );
1498
1499 assert_eq!(
1500 serde_json::to_value(State::Closed {
1501 reason: CloseReason::Solved
1502 })
1503 .unwrap(),
1504 serde_json::json!({ "status": "closed", "reason": "solved" })
1505 );
1506 }
1507
1508 #[test]
1509 fn test_issue_all() {
1510 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1511 let mut issues = Cache::no_cache(&*repo).unwrap();
1512 issues
1513 .create(
1514 cob::Title::new("First").unwrap(),
1515 "Blah",
1516 &[],
1517 &[],
1518 [],
1519 &node.signer,
1520 )
1521 .unwrap();
1522 issues
1523 .create(
1524 cob::Title::new("Second").unwrap(),
1525 "Blah",
1526 &[],
1527 &[],
1528 [],
1529 &node.signer,
1530 )
1531 .unwrap();
1532 issues
1533 .create(
1534 cob::Title::new("Third").unwrap(),
1535 "Blah",
1536 &[],
1537 &[],
1538 [],
1539 &node.signer,
1540 )
1541 .unwrap();
1542
1543 let issues = issues
1544 .list()
1545 .unwrap()
1546 .map(|r| r.map(|(_, i)| i))
1547 .collect::<Result<Vec<_>, _>>()
1548 .unwrap();
1549
1550 assert_eq!(issues.len(), 3);
1551
1552 issues.iter().find(|i| i.title() == "First").unwrap();
1553 issues.iter().find(|i| i.title() == "Second").unwrap();
1554 issues.iter().find(|i| i.title() == "Third").unwrap();
1555 }
1556
1557 #[test]
1558 fn test_issue_multilines() {
1559 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1560 let mut issues = Cache::no_cache(&*repo).unwrap();
1561 let created = issues
1562 .create(
1563 cob::Title::new("My first issue").unwrap(),
1564 "Blah blah blah.\nYah yah yah",
1565 &[],
1566 &[],
1567 [],
1568 &node.signer,
1569 )
1570 .unwrap();
1571
1572 let (id, created) = (created.id, created.issue);
1573 let issue = issues.get(&id).unwrap().unwrap();
1574
1575 assert_eq!(created, issue);
1576 assert_eq!(issue.title(), "My first issue");
1577 assert_eq!(issue.author().id, Did::from(node.signer.public_key()));
1578 assert_eq!(issue.description(), "Blah blah blah.\nYah yah yah");
1579 assert_eq!(issue.comments().count(), 1);
1580 assert_eq!(issue.state(), &State::Open);
1581 }
1582
1583 #[test]
1584 fn test_embeds() {
1585 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1586 let mut issues = Cache::no_cache(&*repo).unwrap();
1587
1588 let content1 = repo.backend.blob(b"<html>Hello World!</html>").unwrap();
1589 let content2 = repo.backend.blob(b"<html>Hello Radicle!</html>").unwrap();
1590 let content3 = repo.backend.blob(b"body { color: red }").unwrap();
1591
1592 let embed1 = Embed {
1593 name: String::from("example.html"),
1594 content: Uri::from(Oid::from(content1)),
1595 };
1596 let embed2 = Embed {
1597 name: String::from("style.css"),
1598 content: Uri::from(Oid::from(content2)),
1599 };
1600 let embed3 = Embed {
1601 name: String::from("bin"),
1602 content: Uri::from(Oid::from(content3)),
1603 };
1604 let mut issue = issues
1605 .create(
1606 cob::Title::new("My first issue").unwrap(),
1607 "Blah blah blah.",
1608 &[],
1609 &[],
1610 [embed1.clone(), embed2.clone()],
1611 &node.signer,
1612 )
1613 .unwrap();
1614
1615 issue
1616 .comment(
1617 "Here's a binary file",
1618 *issue.id,
1619 [embed3.clone()],
1620 &node.signer,
1621 )
1622 .unwrap();
1623
1624 issue.reload().unwrap();
1625
1626 let (_, c0) = issue.thread().comments().next().unwrap();
1627 let (_, c1) = issue.thread().comments().next_back().unwrap();
1628
1629 let e1 = &c0.embeds()[0];
1630 let e2 = &c0.embeds()[1];
1631 let e3 = &c1.embeds()[0];
1632
1633 let b1 = Oid::try_from(&e1.content).unwrap();
1634 let b2 = Oid::try_from(&e2.content).unwrap();
1635 let b3 = Oid::try_from(&e3.content).unwrap();
1636
1637 assert_eq!(b1, Oid::try_from(&embed1.content).unwrap());
1638 assert_eq!(b2, Oid::try_from(&embed2.content).unwrap());
1639 assert_eq!(b3, Oid::try_from(&embed3.content).unwrap());
1640 }
1641
1642 #[test]
1643 fn test_embeds_edit() {
1644 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1645 let mut issues = Cache::no_cache(&*repo).unwrap();
1646
1647 let content1 = repo.backend.blob(b"<html>Hello World!</html>").unwrap();
1648 let content1_edited = repo.backend.blob(b"<html>Hello Radicle!</html>").unwrap();
1649 let content2 = repo.backend.blob(b"body { color: red }").unwrap();
1650
1651 let embed1 = Embed {
1652 name: String::from("example.html"),
1653 content: Uri::from(Oid::from(content1)),
1654 };
1655 let embed1_edited = Embed {
1656 name: String::from("style.css"),
1657 content: Uri::from(Oid::from(content1_edited)),
1658 };
1659 let embed2 = Embed {
1660 name: String::from("bin"),
1661 content: Uri::from(Oid::from(content2)),
1662 };
1663 let mut issue = issues
1664 .create(
1665 cob::Title::new("My first issue").unwrap(),
1666 "Blah blah blah.",
1667 &[],
1668 &[],
1669 [embed1, embed2],
1670 &node.signer,
1671 )
1672 .unwrap();
1673
1674 issue.reload().unwrap();
1675 issue
1676 .edit_description("My first issue", [embed1_edited.clone()], &node.signer)
1677 .unwrap();
1678 issue.reload().unwrap();
1679
1680 let (_, c0) = issue.thread().comments().next().unwrap();
1681
1682 assert_eq!(c0.embeds().len(), 1);
1683
1684 let e1 = &c0.embeds()[0];
1685 let b1 = Oid::try_from(&e1.content).unwrap();
1686
1687 assert_eq!(e1.content, embed1_edited.content);
1688 assert_eq!(b1, Oid::try_from(&embed1_edited.content).unwrap());
1689 }
1690
1691 #[test]
1692 fn test_invalid_actions() {
1693 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1694 let mut issues = Cache::no_cache(&*repo).unwrap();
1695 let mut issue = issues
1696 .create(
1697 cob::Title::new("My first issue").unwrap(),
1698 "Blah blah blah.",
1699 &[],
1700 &[],
1701 [],
1702 &node.signer,
1703 )
1704 .unwrap();
1705 let missing = arbitrary::oid();
1706
1707 issue
1708 .comment("Invalid", missing, [], &node.signer)
1709 .unwrap_err();
1710 assert_eq!(issue.comments().count(), 1);
1711 issue.reload().unwrap();
1712 assert_eq!(issue.comments().count(), 1);
1713
1714 let cob = cob::get::<Issue, _>(&*repo, Issue::type_name(), issue.id())
1715 .unwrap()
1716 .unwrap();
1717
1718 assert_eq!(cob.history().len(), 1);
1719 assert_eq!(
1720 cob.history().tips().into_iter().collect::<Vec<_>>(),
1721 vec![*issue.id]
1722 );
1723 }
1724
1725 #[test]
1726 fn test_invalid_tx() {
1727 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1728 let mut issues = Cache::no_cache(&*repo).unwrap();
1729 let mut issue = issues
1730 .create(
1731 cob::Title::new("My first issue").unwrap(),
1732 "Blah blah blah.",
1733 &[],
1734 &[],
1735 [],
1736 &node.signer,
1737 )
1738 .unwrap();
1739 let missing = arbitrary::oid();
1740
1741 let mut tx = Transaction::<Issue, _>::default();
1744 tx.comment("Invalid comment", missing, vec![]).unwrap();
1745 tx.commit("Add comment", issue.id, &mut issue.store.raw, &node.signer)
1746 .unwrap_err();
1747
1748 issue.reload().unwrap();
1749 assert_eq!(issue.comments().count(), 1);
1750 }
1751
1752 #[test]
1753 fn test_invalid_tx_reference() {
1754 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1755 let mut issues = Cache::no_cache(&*repo).unwrap();
1756 let issue = issues
1757 .create(
1758 cob::Title::new("My first issue").unwrap(),
1759 "Blah blah blah.",
1760 &[],
1761 &[],
1762 [],
1763 &node.signer,
1764 )
1765 .unwrap();
1766
1767 let mut tx: Transaction<Issue, test::storage::git::Repository> =
1769 Transaction::<Issue, _>::default();
1770 tx.comment("First reply", *issue.id, vec![]).unwrap();
1771 let err = tx.comment("Second reply", *issue.id, vec![]).unwrap_err();
1772 assert_matches!(err, cob::store::Error::ClashingIdentifiers(_, _));
1773 }
1774
1775 #[test]
1776 fn test_invalid_cob() {
1777 use cob::change::Storage as _;
1778 use cob::object::Storage as _;
1779 use nonempty::NonEmpty;
1780
1781 let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
1782 let eve = Device::mock();
1783 let identity = repo.identity().unwrap().head();
1784 let missing = arbitrary::oid();
1785 let type_name = Issue::type_name().clone();
1786 let mut issues = Cache::no_cache(&*repo).unwrap();
1787 let mut issue = issues
1788 .create(
1789 cob::Title::new("My first issue").unwrap(),
1790 "Blah blah blah.",
1791 &[],
1792 &[],
1793 [],
1794 &node.signer,
1795 )
1796 .unwrap();
1797
1798 let cob = cob::get::<NonEmpty<cob::Entry>, _>(&*repo, &type_name, issue.id())
1800 .unwrap()
1801 .unwrap();
1802
1803 assert_eq!(cob.history.len(), 1);
1804 assert_eq!(cob.object.len(), 1);
1805
1806 let action = Action::CommentRedact { id: missing };
1811 let action = cob::store::encoding::encode(action).unwrap();
1812 let contents = NonEmpty::new(action);
1813 let invalid = repo
1814 .store(
1815 Some(identity),
1816 vec![],
1817 &eve,
1818 cob::change::Template {
1819 tips: vec![*issue.id],
1820 embeds: vec![],
1821 contents: contents.clone(),
1822 type_name: type_name.clone(),
1823 message: String::from("Add invalid operation"),
1824 },
1825 )
1826 .unwrap();
1827
1828 repo.update(eve.public_key(), &type_name, &issue.id, &invalid.id)
1829 .unwrap();
1830
1831 let cob = cob::get::<NonEmpty<cob::Entry>, _>(&*repo, &type_name, issue.id())
1834 .unwrap()
1835 .unwrap();
1836
1837 assert_eq!(cob.history.len(), 2);
1838 assert_eq!(cob.object.len(), 2);
1839 assert_eq!(cob.object.last().contents(), &contents);
1840
1841 let cob = cob::get::<Issue, _>(&*repo, &type_name, issue.id())
1843 .unwrap()
1844 .unwrap();
1845 assert_eq!(cob.history.len(), 1);
1846 assert_eq!(cob.object.comments().count(), 1);
1847 assert!(cob.object.comment(&issue.id).is_some());
1848
1849 issue.reload().unwrap();
1851 issue
1852 .comment("Valid comment", *issue.id, vec![], &node.signer)
1853 .unwrap();
1854 issue.reload().unwrap();
1855 assert_eq!(issue.comments().count(), 2);
1856 assert_eq!(issue.thread.timeline().count(), 2);
1857 assert_eq!(issue.comments().last().unwrap().1.body(), "Valid comment");
1858
1859 let cob = cob::get::<NonEmpty<cob::Entry>, _>(&*repo, &type_name, issue.id())
1861 .unwrap()
1862 .unwrap();
1863
1864 assert_eq!(cob.history.len(), 3);
1865 assert_eq!(cob.object.len(), 3);
1866
1867 issue
1870 .comment("Eve's comment", *issue.id, vec![], &eve)
1871 .unwrap();
1872
1873 let cob = cob::get::<NonEmpty<cob::Entry>, _>(&*repo, &type_name, issue.id())
1874 .unwrap()
1875 .unwrap();
1876
1877 assert_eq!(issue.comments().count(), 3);
1880 assert_eq!(cob.history.len(), 3);
1881 assert_eq!(cob.object.len(), 3);
1882 }
1883}