radicle/cob/
issue.rs

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
27/// Issue operation.
28pub type Op = cob::Op<Action>;
29
30/// Type name of an issue.
31pub static TYPENAME: LazyLock<TypeName> =
32    LazyLock::new(|| FromStr::from_str("xyz.radicle.issue").expect("type name is valid"));
33
34/// Identifier for an issue.
35pub 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/// Error updating or creating issues.
47#[derive(Error, Debug)]
48pub enum Error {
49    /// Error loading the identity document.
50    #[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    /// Action not authorized.
59    #[error("{0} not authorized to apply {1:?}")]
60    NotAuthorized(ActorId, Action),
61    /// Action not allowed.
62    #[error("action is not allowed: {0}")]
63    NotAllowed(EntryId),
64    /// Title is invalid.
65    #[error("invalid title: {0:?}")]
66    InvalidTitle(String),
67    /// The identity doc is missing.
68    #[error("identity document missing")]
69    MissingIdentity,
70    /// General error initializing an issue.
71    #[error("initialization failed: {0}")]
72    Init(&'static str),
73    /// Error decoding an operation.
74    #[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/// Reason why an issue was closed.
96#[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/// Issue state.
114#[derive(Debug, Default, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase", tag = "status")]
116pub enum State {
117    /// The issue is closed.
118    Closed { reason: CloseReason },
119    /// The issue is open.
120    #[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/// Issue state. Accumulates [`Action`].
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct Issue {
146    /// Actors assigned to this issue.
147    pub(super) assignees: BTreeSet<Did>,
148    /// Title of the issue.
149    pub(super) title: String,
150    /// Current state of the issue.
151    pub(super) state: State,
152    /// Associated labels.
153    pub(super) labels: BTreeSet<Label>,
154    /// Discussion around this issue.
155    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                    // Note that this shouldn't really happen since there's no concurrency in the
193                    // root operation.
194                    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    /// Construct a new issue.
254    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    /// Get replies to a specific comment.
321    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    /// Iterate over all top-level replies. Does not include the top-level root comment.
329    /// Use [`Issue::comments`] to get all comments including the "root" comment.
330    pub fn replies(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment)> {
331        self.comments().skip(1)
332    }
333
334    /// Apply authorization rules on issue actions.
335    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            // A delegate is authorized to do all actions.
343            return Ok(Authorization::Allow);
344        }
345        let author: ActorId = *self.author().id().as_key();
346        let outcome = match action {
347            // Only delegate can assign someone to an issue.
348            Action::Assign { assignees } => {
349                if assignees == &self.assignees {
350                    // No-op is allowed for backwards compatibility.
351                    Authorization::Allow
352                } else {
353                    Authorization::Deny
354                }
355            }
356            // Issue authors can edit their own issues.
357            Action::Edit { .. } => Authorization::from(*actor == author),
358            // Issue authors can close or re-open their own issue.
359            Action::Lifecycle { state } => Authorization::from(match state {
360                State::Closed { .. } => *actor == author,
361                State::Open => *actor == author,
362            }),
363            // Only delegate can label an issue.
364            Action::Label { labels } => {
365                if labels == &self.labels {
366                    // No-op is allowed for backwards compatibility.
367                    Authorization::Allow
368                } else {
369                    Authorization::Deny
370                }
371            }
372            // All roles can comment on an issues
373            Action::Comment { .. } => Authorization::Allow,
374            // All roles can edit or redact their own comments.
375            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            // All roles can react to a comment on an issue.
387            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    /// Apply a single action to the issue.
414    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    /// Assign DIDs to the issue.
491    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    /// Edit an issue comment.
498    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    /// Set the issue title.
513    pub fn edit(&mut self, title: cob::Title) -> Result<(), store::Error> {
514        self.push(Action::Edit { title })
515    }
516
517    /// Redact a comment.
518    pub fn redact_comment(&mut self, id: CommentId) -> Result<(), store::Error> {
519        self.push(Action::CommentRedact { id })
520    }
521
522    /// Lifecycle an issue.
523    pub fn lifecycle(&mut self, state: State) -> Result<(), store::Error> {
524        self.push(Action::Lifecycle { state })
525    }
526
527    /// Comment on an issue.
528    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    /// Label an issue.
543    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    /// React to an issue comment.
550    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    ////////////////////////////////////////////////////////////////////////////////////////////////
564
565    /// Create the issue thread.
566    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    /// Reload the issue data from storage.
604    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    /// Get the issue id.
614    pub fn id(&self) -> &ObjectId {
615        &self.id
616    }
617
618    /// Assign one or more actors to an issue.
619    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    /// Set the issue title.
631    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    /// Set the issue description.
639    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    /// Lifecycle an issue.
656    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    /// Comment on an issue.
664    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    /// Edit a comment.
681    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    /// Redact a comment.
698    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    /// Label an issue.
706    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    /// React to an issue comment.
718    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/// Detailed information on issue states
787#[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    /// Total count.
796    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    /// Open an issues store.
806    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    /// Create a new issue.
819    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    /// Remove an issue.
858    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    /// Get an issue.
872    pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, store::Error> {
873        self.raw.get(id)
874    }
875
876    /// Get an issue mutably.
877    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    /// Issues count by state.
896    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/// Issue action.
913#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
914#[serde(tag = "type", rename_all = "camelCase")]
915pub enum Action {
916    /// Assign issue to an actor.
917    #[serde(rename = "assign")]
918    Assign { assignees: BTreeSet<Did> },
919
920    /// Edit issue title.
921    #[serde(rename = "edit")]
922    Edit { title: cob::Title },
923
924    /// Transition to a different state.
925    #[serde(rename = "lifecycle")]
926    Lifecycle { state: State },
927
928    /// Modify issue labels.
929    #[serde(rename = "label")]
930    Label { labels: BTreeSet<Label> },
931
932    /// Comment on a thread.
933    #[serde(rename_all = "camelCase")]
934    #[serde(rename = "comment")]
935    Comment {
936        /// Comment body.
937        body: String,
938        /// Comment this is a reply to.
939        /// Should be [`None`] if it's the top-level comment.
940        /// Should be the root [`CommentId`] if it's a top-level comment.
941        #[serde(default, skip_serializing_if = "Option::is_none")]
942        reply_to: Option<CommentId>,
943        /// Embeded content.
944        #[serde(default, skip_serializing_if = "Vec::is_empty")]
945        embeds: Vec<Embed<Uri>>,
946    },
947
948    /// Edit a comment.
949    #[serde(rename = "comment.edit")]
950    CommentEdit {
951        /// Comment being edited.
952        id: CommentId,
953        /// New value for the comment body.
954        body: String,
955        /// New value for the embeds list.
956        embeds: Vec<Embed<Uri>>,
957    },
958
959    /// Redact a change. Not all changes can be redacted.
960    #[serde(rename = "comment.redact")]
961    CommentRedact { id: CommentId },
962
963    /// React to a comment.
964    #[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        // TODO: Test multiple reactions from same author and different authors
1325    }
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        // The root thread op id is always the same.
1436        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        // The root thread op id is always the same.
1476        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        // Can't redact root comment.
1489        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        // An invalid comment which points to a missing parent.
1742        // Even creating it via a transaction will trigger an error.
1743        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        // Comments require references, so adding two of them to the same transaction errors.
1768        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        // Initially, there is one node in the DAG.
1799        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        // We have a valid issue. Now we're going to add an invalid action to it, by bypassing
1807        // the COB API. We do this using a different key, so that valid actions by
1808        // our issue author don't overwrite the invalid action, since there is
1809        // only one ref per COB per user.
1810        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        // If we fetch the COB with its history, *without* trying to interpret it as an issue,
1832        // we'll see that all entries, including the invalid one are there.
1833        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        // However, if we try to fetch it as an *issue*, the invalid comment is pruned.
1842        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        // Additionally, when adding a *valid* comment, it does not build upon the bad operation.
1850        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        // The actual DAG contains 3 nodes, but only 2 were loaded as an issue.
1860        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        // If Eve now writes a valid comment via the `Issue` type, it will overwrite her invalid
1868        // one, since it won't be loaded as a tip.
1869        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        // There are three nodes still, but they are all valid comments.
1878        // The invalid comment of Eve was replaced with a valid one.
1879        assert_eq!(issue.comments().count(), 3);
1880        assert_eq!(cob.history.len(), 3);
1881        assert_eq!(cob.object.len(), 3);
1882    }
1883}