Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Implement issues using radicle CRDTs
Alexis Sellier committed 3 years ago
commit 171b5a45f7486515b00d9e530f730962e003170a
parent 36ccab2d7a589d926c5c8711ea3428cfcf1e8cf3
12 files changed +702 -830
modified radicle-cli/src/commands/issue.rs
@@ -7,9 +7,8 @@ use anyhow::{anyhow, Context as _};
use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};

-
use radicle::cob::automerge::store::Store;
-
use radicle::cob::common::{Label, Reaction};
-
use radicle::cob::issue::{CloseReason, IssueId, State};
+
use radicle::cob::common::{Reaction, Tag};
+
use radicle::cob::issue::{CloseReason, IssueId, Issues, Status};
use radicle::storage::WriteStorage;

pub const HELP: Help = Help {
@@ -34,7 +33,7 @@ Options
#[derive(serde::Deserialize, serde::Serialize, Debug)]
pub struct Metadata {
    title: String,
-
    labels: Vec<Label>,
+
    labels: Vec<Tag>,
}

#[derive(Debug, PartialEq, Eq)]
@@ -60,7 +59,7 @@ pub enum Operation {
    },
    State {
        id: IssueId,
-
        state: State,
+
        state: Status,
    },
    Delete {
        id: IssueId,
@@ -87,7 +86,7 @@ impl Args for Options {
        let mut title: Option<String> = None;
        let mut reaction: Option<Reaction> = None;
        let mut description: Option<String> = None;
-
        let mut state: Option<State> = None;
+
        let mut state: Option<Status> = None;

        while let Some(arg) = parser.next()? {
            match arg {
@@ -98,15 +97,15 @@ impl Args for Options {
                    title = Some(parser.value()?.to_string_lossy().into());
                }
                Long("closed") if op == Some(OperationName::State) => {
-
                    state = Some(State::Closed {
+
                    state = Some(Status::Closed {
                        reason: CloseReason::Other,
                    });
                }
                Long("open") if op == Some(OperationName::State) => {
-
                    state = Some(State::Open);
+
                    state = Some(Status::Open);
                }
                Long("solved") if op == Some(OperationName::State) => {
-
                    state = Some(State::Closed {
+
                    state = Some(Status::Closed {
                        reason: CloseReason::Solved,
                    });
                }
@@ -170,23 +169,23 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let storage = &profile.storage;
    let (_, id) = radicle::rad::cwd()?;
    let repo = storage.repository(id)?;
-
    let cobs = Store::open(*signer.public_key(), &repo)?;
-
    let issues = cobs.issues();
+
    let mut issues = Issues::open(*signer.public_key(), &repo)?;

    match options.op {
        Operation::Create {
            title: Some(title),
            description: Some(description),
        } => {
-
            issues.create(&title, &description, &[], &signer)?;
+
            issues.create(title, description, &[], &signer)?;
        }
        Operation::State { id, state } => {
-
            issues.lifecycle(&id, state, &signer)?;
+
            let mut issue = issues.get_mut(&id)?;
+
            issue.lifecycle(state, &signer)?;
        }
        Operation::React { id, reaction } => {
-
            if let Some(issue) = issues.get(&id)? {
+
            if let Ok(mut issue) = issues.get_mut(&id) {
                let comment_id = term::comment_select(&issue).unwrap();
-
                issues.react(&id, comment_id, reaction, &signer)?;
+
                issue.react(comment_id, reaction, &signer)?;
            }
        }
        Operation::Create { title, description } => {
@@ -234,12 +233,13 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            }
        }
        Operation::List => {
-
            for (id, issue) in issues.all()? {
+
            for result in issues.all()? {
+
                let (id, issue) = result?;
                println!("{} {}", id, issue.title());
            }
        }
        Operation::Delete { id } => {
-
            issues.remove(&id, &signer)?;
+
            issues.remove(&id)?;
        }
    }

modified radicle-cli/src/terminal/io.rs
@@ -3,8 +3,8 @@ use std::str::FromStr;

use dialoguer::{console::style, console::Style, theme::ColorfulTheme, Input, Password};

-
use radicle::cob::common::CommentId;
use radicle::cob::issue::Issue;
+
use radicle::cob::thread::CommentId;
use radicle::crypto::ssh::keystore::Passphrase;
use radicle::crypto::Signer;
use radicle::profile::env::RAD_PASSPHRASE;
@@ -378,19 +378,20 @@ where
pub fn comment_select(issue: &Issue) -> Option<CommentId> {
    let selection = dialoguer::Select::with_theme(&theme())
        .with_prompt("Which comment do you want to react to?")
-
        .item(&issue.description().to_string())
+
        .item(issue.description().unwrap_or_default())
        .items(
            &issue
                .comments()
-
                .iter()
-
                .map(|p| p.body.clone())
+
                .map(|(_, i)| i.body.clone())
                .collect::<Vec<_>>(),
        )
-
        .default(CommentId::root().into())
+
        .default(0)
        .interact_opt()
        .unwrap();

-
    selection.map(CommentId::from)
+
    selection
+
        .and_then(|n| issue.comments().nth(n))
+
        .map(|(id, _)| *id)
}

pub fn markdown(content: &str) {
modified radicle/src/cob/automerge.rs
@@ -1,5 +1,4 @@
pub mod doc;
-
pub mod issue;
pub mod label;
pub mod patch;
pub mod shared;
deleted radicle/src/cob/automerge/issue.rs
@@ -1,682 +0,0 @@
-
#![allow(clippy::large_enum_variant)]
-
use std::collections::HashSet;
-
use std::convert::TryFrom;
-
use std::ops::ControlFlow;
-

-
use automerge::{Automerge, ObjType, ScalarValue, Value};
-

-
use crate::cob::automerge::doc::{Document, DocumentError};
-
use crate::cob::automerge::shared;
-
use crate::cob::automerge::shared::*;
-
use crate::cob::automerge::store::{Error, Store};
-
use crate::cob::automerge::transaction::{Transaction, TransactionError};
-
use crate::cob::automerge::value::{FromValue, ValueError};
-
use crate::cob::common::*;
-
use crate::cob::issue::*;
-
use crate::cob::{Contents, History, ObjectId, Timestamp, TypeName};
-
use crate::prelude::*;
-

-
impl From<State> for ScalarValue {
-
    fn from(state: State) -> Self {
-
        match state {
-
            State::Open => ScalarValue::from("open"),
-
            State::Closed {
-
                reason: CloseReason::Solved,
-
            } => ScalarValue::from("solved"),
-
            State::Closed {
-
                reason: CloseReason::Other,
-
            } => ScalarValue::from("closed"),
-
        }
-
    }
-
}
-

-
impl<'a> FromValue<'a> for State {
-
    fn from_value(value: Value) -> Result<Self, ValueError> {
-
        let state = value.to_str().ok_or(ValueError::InvalidType)?;
-

-
        match state {
-
            "open" => Ok(Self::Open),
-
            "closed" => Ok(Self::Closed {
-
                reason: CloseReason::Other,
-
            }),
-
            "solved" => Ok(Self::Closed {
-
                reason: CloseReason::Solved,
-
            }),
-
            _ => Err(ValueError::InvalidValue(value.to_string())),
-
        }
-
    }
-
}
-

-
impl FromHistory for Issue {
-
    fn type_name() -> &'static TypeName {
-
        &TYPENAME
-
    }
-

-
    fn from_history(history: &History) -> Result<Self, Error> {
-
        let doc = history.traverse(Automerge::new(), |mut doc, entry| {
-
            let bytes = entry.contents();
-
            match automerge::Change::from_bytes(bytes.clone()) {
-
                Ok(change) => {
-
                    doc.apply_changes([change]).ok();
-
                }
-
                Err(_err) => {
-
                    // Ignore
-
                }
-
            }
-
            ControlFlow::Continue(doc)
-
        });
-
        let issue = Issue::try_from(doc)?;
-

-
        Ok(issue)
-
    }
-
}
-

-
impl TryFrom<&History> for Issue {
-
    type Error = Error;
-

-
    fn try_from(history: &History) -> Result<Self, Self::Error> {
-
        Issue::from_history(history)
-
    }
-
}
-

-
impl TryFrom<Automerge> for Issue {
-
    type Error = DocumentError;
-

-
    fn try_from(doc: Automerge) -> Result<Self, Self::Error> {
-
        let doc = Document::new(&doc);
-
        let obj_id = doc.get_id(automerge::ObjId::Root, "issue")?;
-
        let title = doc.get(&obj_id, "title")?;
-
        let comment_id = doc.get_id(&obj_id, "comment")?;
-
        let author = doc.get(&obj_id, "author").map(Author::new)?;
-
        let state = doc.get(&obj_id, "state")?;
-
        let timestamp = doc.get(&obj_id, "timestamp")?;
-

-
        let comment = shared::lookup::comment(doc, &comment_id)?;
-
        let discussion: Discussion = doc.list(&obj_id, "discussion", shared::lookup::thread)?;
-
        let labels: HashSet<Label> = doc.keys(&obj_id, "labels")?;
-

-
        Ok(Self {
-
            title,
-
            state,
-
            author,
-
            comment,
-
            discussion,
-
            labels,
-
            timestamp,
-
        })
-
    }
-
}
-

-
pub struct IssueStore<'a> {
-
    store: Store<'a, Issue>,
-
}
-

-
impl<'a> IssueStore<'a> {
-
    /// Create a new issue store.
-
    pub fn new(store: Store<'a, Issue>) -> Self {
-
        Self { store }
-
    }
-

-
    /// Get an issue by id.
-
    pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, Error> {
-
        self.store.get(id)
-
    }
-

-
    /// Create an issue.
-
    pub fn create<G: Signer>(
-
        &self,
-
        title: &str,
-
        description: &str,
-
        labels: &[Label],
-
        signer: &G,
-
    ) -> Result<IssueId, Error> {
-
        let author = self.store.author();
-
        let timestamp = Timestamp::now();
-
        let contents = events::create(&author, title, description, timestamp, labels)?;
-
        let cob = self.store.create("Create issue", contents, signer)?;
-

-
        Ok(*cob.id())
-
    }
-

-
    /// Remove an issue.
-
    pub fn remove<G: Signer>(&self, _issue_id: &IssueId, _signer: &G) -> Result<(), Error> {
-
        todo!()
-
    }
-

-
    /// Comment on an issue.
-
    pub fn comment<G: Signer>(
-
        &self,
-
        issue_id: &IssueId,
-
        body: &str,
-
        signer: &G,
-
    ) -> Result<(), Error> {
-
        let author = self.store.author();
-
        let mut issue = self.store.get_raw(issue_id)?;
-
        let timestamp = Timestamp::now();
-
        let changes = events::comment(&mut issue, &author, body, timestamp)?;
-

-
        self.store
-
            .update(*issue_id, "Add comment", changes, signer)?;
-

-
        Ok(())
-
    }
-

-
    /// Life-cycle an issue, eg. open or close it.
-
    pub fn lifecycle<G: Signer>(
-
        &self,
-
        issue_id: &IssueId,
-
        state: State,
-
        signer: &G,
-
    ) -> Result<(), Error> {
-
        let author = self.store.author();
-
        let mut issue = self.store.get_raw(issue_id)?;
-
        let changes = events::lifecycle(&mut issue, &author, state)?;
-

-
        self.store.update(*issue_id, "Lifecycle", changes, signer)?;
-

-
        Ok(())
-
    }
-

-
    /// Label an issue.
-
    pub fn label<G: Signer>(
-
        &self,
-
        issue_id: &IssueId,
-
        labels: &[Label],
-
        signer: &G,
-
    ) -> Result<(), Error> {
-
        let author = self.store.author();
-
        let mut issue = self.store.get_raw(issue_id)?;
-
        let changes = events::label(&mut issue, &author, labels)?;
-

-
        self.store.update(*issue_id, "Add label", changes, signer)?;
-

-
        Ok(())
-
    }
-

-
    /// React to an issue comment.
-
    pub fn react<G: Signer>(
-
        &self,
-
        issue_id: &IssueId,
-
        comment_id: CommentId,
-
        reaction: Reaction,
-
        signer: &G,
-
    ) -> Result<(), Error> {
-
        let author = self.store.author();
-
        let mut issue = self.store.get_raw(issue_id)?;
-
        let changes = events::react(&mut issue, comment_id, &author, &[reaction])?;
-

-
        self.store.update(*issue_id, "React", changes, signer)?;
-

-
        Ok(())
-
    }
-

-
    /// Reply to an issue comment.
-
    pub fn reply<G: Signer>(
-
        &self,
-
        issue_id: &IssueId,
-
        comment_id: CommentId,
-
        reply: &str,
-
        signer: &G,
-
    ) -> Result<(), Error> {
-
        let author = self.store.author();
-
        let mut issue = self.store.get_raw(issue_id)?;
-
        let changes = events::reply(&mut issue, comment_id, &author, reply, Timestamp::now())?;
-

-
        self.store.update(*issue_id, "Reply", changes, signer)?;
-

-
        Ok(())
-
    }
-

-
    /// Get all issues, sorted by time.
-
    pub fn all(&self) -> Result<Vec<(IssueId, Issue)>, Error> {
-
        let mut issues = self.store.list()?;
-
        issues.sort_by_key(|(_, i)| i.timestamp);
-

-
        Ok(issues)
-
    }
-

-
    /// Get the issue count.
-
    pub fn count(&self) -> Result<usize, Error> {
-
        let issues = self.store.list()?;
-

-
        Ok(issues.len())
-
    }
-
}
-

-
/// Issue events.
-
mod events {
-
    use super::*;
-
    use automerge::{
-
        transaction::{CommitOptions, Transactable},
-
        ObjId,
-
    };
-

-
    pub fn create(
-
        author: &Author,
-
        title: &str,
-
        description: &str,
-
        timestamp: Timestamp,
-
        labels: &[Label],
-
    ) -> Result<Contents, TransactionError> {
-
        let title = title.trim();
-
        if title.is_empty() {
-
            return Err(TransactionError::InvalidValue("title"));
-
        }
-

-
        let mut doc = Automerge::new();
-
        let _issue = doc
-
            .transact_with::<_, _, TransactionError, _, ()>(
-
                |_| CommitOptions::default().with_message("Create issue".to_owned()),
-
                |tx| {
-
                    let issue = tx.put_object(ObjId::Root, "issue", ObjType::Map)?;
-

-
                    tx.put(&issue, "title", title)?;
-
                    tx.put(&issue, "author", author)?;
-
                    tx.put(&issue, "state", State::Open)?;
-
                    tx.put(&issue, "timestamp", timestamp)?;
-
                    tx.put_object(&issue, "discussion", ObjType::List)?;
-

-
                    let labels_id = tx.put_object(&issue, "labels", ObjType::Map)?;
-
                    for label in labels {
-
                        tx.put(&labels_id, label.name().trim(), true)?;
-
                    }
-

-
                    // Nb. The top-level comment doesn't have a `replies` field.
-
                    let comment_id = tx.put_object(&issue, "comment", ObjType::Map)?;
-

-
                    tx.put(&comment_id, "body", description.trim())?;
-
                    tx.put(&comment_id, "author", author)?;
-
                    tx.put(&comment_id, "timestamp", timestamp)?;
-
                    tx.put_object(&comment_id, "reactions", ObjType::Map)?;
-

-
                    Ok(issue)
-
                },
-
            )
-
            .map_err(|failure| failure.error)?
-
            .result;
-

-
        Ok(doc.save_incremental())
-
    }
-

-
    pub fn comment(
-
        issue: &mut Automerge,
-
        author: &Author,
-
        body: &str,
-
        timestamp: Timestamp,
-
    ) -> Result<Contents, TransactionError> {
-
        let _comment = issue
-
            .transact_with::<_, _, TransactionError, _, ()>(
-
                |_| CommitOptions::default().with_message("Add comment".to_owned()),
-
                |tx| {
-
                    let mut tx = Transaction::new(tx);
-
                    let (_obj, obj_id) = tx.get(ObjId::Root, "issue")?;
-
                    let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
-

-
                    let length = tx.length(&discussion_id);
-
                    let comment = tx.insert_object(&discussion_id, length, ObjType::Map)?;
-

-
                    tx.put(&comment, "author", author)?;
-
                    tx.put(&comment, "body", body.trim())?;
-
                    tx.put(&comment, "timestamp", timestamp)?;
-
                    tx.put_object(&comment, "replies", ObjType::List)?;
-
                    tx.put_object(&comment, "reactions", ObjType::Map)?;
-

-
                    Ok(comment)
-
                },
-
            )
-
            .map_err(|failure| failure.error)?
-
            .result;
-

-
        #[allow(clippy::unwrap_used)]
-
        let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
-

-
        Ok(change)
-
    }
-

-
    pub fn lifecycle(
-
        issue: &mut Automerge,
-
        author: &Author,
-
        state: State,
-
    ) -> Result<Contents, TransactionError> {
-
        issue
-
            .transact_with::<_, _, TransactionError, _, ()>(
-
                |_| CommitOptions::default().with_message(state.lifecycle_message()),
-
                |tx| {
-
                    let mut tx = Transaction::new(tx);
-
                    let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
-
                    tx.put(&obj_id, "state", state)?;
-
                    tx.put(&obj_id, "author", author)?;
-

-
                    Ok(())
-
                },
-
            )
-
            .map_err(|failure| failure.error)?;
-

-
        #[allow(clippy::unwrap_used)]
-
        let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
-

-
        Ok(change)
-
    }
-

-
    pub fn label(
-
        issue: &mut Automerge,
-
        _author: &Author,
-
        labels: &[Label],
-
    ) -> Result<Contents, TransactionError> {
-
        issue
-
            .transact_with::<_, _, TransactionError, _, ()>(
-
                |_| CommitOptions::default().with_message("Label issue".to_owned()),
-
                |tx| {
-
                    let mut tx = Transaction::new(tx);
-
                    let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
-
                    let (_, labels_id) = tx.get(&obj_id, "labels")?;
-

-
                    for label in labels {
-
                        tx.put(&labels_id, label.name().trim(), true)?;
-
                    }
-
                    Ok(())
-
                },
-
            )
-
            .map_err(|failure| failure.error)?;
-

-
        #[allow(clippy::unwrap_used)]
-
        let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
-

-
        Ok(change)
-
    }
-

-
    pub fn reply(
-
        issue: &mut Automerge,
-
        comment_id: CommentId,
-
        author: &Author,
-
        body: &str,
-
        timestamp: Timestamp,
-
    ) -> Result<Contents, TransactionError> {
-
        issue
-
            .transact_with::<_, _, TransactionError, _, ()>(
-
                |_| CommitOptions::default().with_message("Reply".to_owned()),
-
                |tx| {
-
                    let mut tx = Transaction::new(tx);
-
                    let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
-
                    let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
-
                    let (_, comment_id) = tx.get(&discussion_id, usize::from(comment_id))?;
-
                    let (_, replies_id) = tx.get(&comment_id, "replies")?;
-

-
                    let length = tx.length(&replies_id);
-
                    let reply = tx.insert_object(&replies_id, length, ObjType::Map)?;
-

-
                    // Nb. Replies don't themselves have replies.
-
                    tx.put(&reply, "author", author)?;
-
                    tx.put(&reply, "body", body.trim())?;
-
                    tx.put(&reply, "timestamp", timestamp)?;
-
                    tx.put_object(&reply, "reactions", ObjType::Map)?;
-

-
                    Ok(())
-
                },
-
            )
-
            .map_err(|failure| failure.error)?;
-

-
        #[allow(clippy::unwrap_used)]
-
        let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
-

-
        Ok(change)
-
    }
-

-
    pub fn react(
-
        issue: &mut Automerge,
-
        comment_id: CommentId,
-
        author: &Author,
-
        reactions: &[Reaction],
-
    ) -> Result<Contents, TransactionError> {
-
        issue
-
            .transact_with::<_, _, TransactionError, _, ()>(
-
                |_| CommitOptions::default().with_message("React".to_owned()),
-
                |tx| {
-
                    let mut tx = Transaction::new(tx);
-
                    let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
-
                    let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
-
                    let (_, comment_id) = if comment_id == CommentId::root() {
-
                        tx.get(&obj_id, "comment")?
-
                    } else {
-
                        tx.get(&discussion_id, usize::from(comment_id) - 1)?
-
                    };
-
                    let (_, reactions_id) = tx.get(&comment_id, "reactions")?;
-

-
                    for reaction in reactions {
-
                        let key = reaction.emoji.to_string();
-
                        let reaction_id = if let Some((_, reaction_id)) =
-
                            tx.try_get(&reactions_id, key)?
-
                        {
-
                            reaction_id
-
                        } else {
-
                            tx.put_object(&reactions_id, reaction.emoji.to_string(), ObjType::Map)?
-
                        };
-
                        tx.put(&reaction_id, author.id.to_human(), true)?;
-
                    }
-

-
                    Ok(())
-
                },
-
            )
-
            .map_err(|failure| failure.error)?;
-

-
        #[allow(clippy::unwrap_used)]
-
        let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
-

-
        Ok(change)
-
    }
-
}
-

-
#[cfg(test)]
-
mod test {
-
    use super::*;
-
    use crate::test;
-

-
    #[test]
-
    fn test_issue_create_and_get() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let (_, signer, project) = test::setup::context(&tmp);
-
        let store = Store::open(*signer.public_key(), &project).unwrap();
-
        let issues = store.issues();
-
        let timestamp = Timestamp::now();
-
        let issue_id = issues
-
            .create("My first issue", "Blah blah blah.", &[], &signer)
-
            .unwrap();
-
        let issue = issues.get(&issue_id).unwrap().unwrap();
-

-
        assert_eq!(issue.title(), "My first issue");
-
        assert_eq!(issue.author(), &store.author());
-
        assert_eq!(issue.description(), "Blah blah blah.");
-
        assert_eq!(issue.comments().len(), 0);
-
        assert_eq!(issue.state(), State::Open);
-
        assert!(issue.timestamp() >= timestamp);
-
    }
-

-
    #[test]
-
    fn test_issue_create_and_change_state() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let (_, signer, project) = test::setup::context(&tmp);
-
        let store = Store::open(*signer.public_key(), &project).unwrap();
-
        let issues = store.issues();
-
        let issue_id = issues
-
            .create("My first issue", "Blah blah blah.", &[], &signer)
-
            .unwrap();
-

-
        issues
-
            .lifecycle(
-
                &issue_id,
-
                State::Closed {
-
                    reason: CloseReason::Other,
-
                },
-
                &signer,
-
            )
-
            .unwrap();
-

-
        let issue = issues.get(&issue_id).unwrap().unwrap();
-
        assert_eq!(
-
            issue.state(),
-
            State::Closed {
-
                reason: CloseReason::Other
-
            }
-
        );
-

-
        issues.lifecycle(&issue_id, State::Open, &signer).unwrap();
-
        let issue = issues.get(&issue_id).unwrap().unwrap();
-
        assert_eq!(issue.state(), State::Open);
-
    }
-

-
    #[test]
-
    fn test_issue_react() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let (_, signer, project) = test::setup::context(&tmp);
-
        let store = Store::open(*signer.public_key(), &project).unwrap();
-
        let issues = store.issues();
-
        let issue_id = issues
-
            .create("My first issue", "Blah blah blah.", &[], &signer)
-
            .unwrap();
-

-
        let reaction = Reaction::new('🥳').unwrap();
-
        issues
-
            .react(&issue_id, CommentId::root(), reaction, &signer)
-
            .unwrap();
-

-
        let issue = issues.get(&issue_id).unwrap().unwrap();
-
        let count = issue.reactions()[&reaction];
-

-
        // TODO: Test multiple reactions from same author and different authors
-

-
        assert_eq!(count, 1);
-
    }
-

-
    #[test]
-
    fn test_issue_reply() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let (_, signer, project) = test::setup::context(&tmp);
-
        let store = Store::open(*signer.public_key(), &project).unwrap();
-
        let issues = store.issues();
-
        let issue_id = issues
-
            .create("My first issue", "Blah blah blah.", &[], &signer)
-
            .unwrap();
-

-
        issues.comment(&issue_id, "Ho ho ho.", &signer).unwrap();
-
        issues
-
            .reply(&issue_id, CommentId::root(), "Hi hi hi.", &signer)
-
            .unwrap();
-
        issues
-
            .reply(&issue_id, CommentId::root(), "Ha ha ha.", &signer)
-
            .unwrap();
-

-
        let issue = issues.get(&issue_id).unwrap().unwrap();
-
        let reply1 = &issue.comments()[0].replies[0];
-
        let reply2 = &issue.comments()[0].replies[1];
-

-
        assert_eq!(reply1.body, "Hi hi hi.");
-
        assert_eq!(reply2.body, "Ha ha ha.");
-
    }
-

-
    #[test]
-
    fn test_issue_label() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let (_, signer, project) = test::setup::context(&tmp);
-
        let store = Store::open(*signer.public_key(), &project).unwrap();
-
        let issues = store.issues();
-
        let issue_id = issues
-
            .create("My first issue", "Blah blah blah.", &[], &signer)
-
            .unwrap();
-

-
        let bug_label = Label::new("bug").unwrap();
-
        let wontfix_label = Label::new("wontfix").unwrap();
-

-
        issues
-
            .label(&issue_id, &[bug_label.clone()], &signer)
-
            .unwrap();
-
        issues
-
            .label(&issue_id, &[wontfix_label.clone()], &signer)
-
            .unwrap();
-

-
        let issue = issues.get(&issue_id).unwrap().unwrap();
-
        let labels = issue.labels();
-

-
        assert!(labels.contains(&bug_label));
-
        assert!(labels.contains(&wontfix_label));
-
    }
-

-
    #[test]
-
    fn test_issue_comment() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let (_, signer, project) = test::setup::context(&tmp);
-
        let store = Store::open(*signer.public_key(), &project).unwrap();
-
        let issues = store.issues();
-
        let now = Timestamp::now();
-
        let author = *signer.public_key();
-
        let issue_id = issues
-
            .create("My first issue", "Blah blah blah.", &[], &signer)
-
            .unwrap();
-

-
        issues.comment(&issue_id, "Ho ho ho.", &signer).unwrap();
-
        issues.comment(&issue_id, "Ha ha ha.", &signer).unwrap();
-

-
        let issue = issues.get(&issue_id).unwrap().unwrap();
-
        let c1 = &issue.comments()[0];
-
        let c2 = &issue.comments()[1];
-

-
        assert_eq!(&c1.body, "Ho ho ho.");
-
        assert_eq!(c1.author.id(), &author);
-
        assert_eq!(&c2.body, "Ha ha ha.");
-
        assert_eq!(c2.author.id(), &author);
-
        assert!(c1.timestamp >= now);
-
    }
-

-
    #[test]
-
    fn test_issue_state_serde() {
-
        assert_eq!(
-
            serde_json::to_value(State::Open).unwrap(),
-
            serde_json::json!({ "status": "open" })
-
        );
-

-
        assert_eq!(
-
            serde_json::to_value(State::Closed {
-
                reason: CloseReason::Solved
-
            })
-
            .unwrap(),
-
            serde_json::json!({ "status": "closed", "reason": "solved" })
-
        );
-
    }
-

-
    #[test]
-
    fn test_issue_all() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let (_, signer, project) = test::setup::context(&tmp);
-
        let store = Store::open(*signer.public_key(), &project).unwrap();
-
        let issues = store.issues();
-
        let author = store.author();
-

-
        let contents =
-
            events::create(&author, "First", "Blah blah.", Timestamp::new(0), &[]).unwrap();
-
        issues
-
            .store
-
            .create("Create issue", contents, &signer)
-
            .unwrap();
-

-
        let contents =
-
            events::create(&author, "Second", "Blah blah.", Timestamp::new(1), &[]).unwrap();
-
        issues
-
            .store
-
            .create("Create issue", contents, &signer)
-
            .unwrap();
-

-
        let contents =
-
            events::create(&author, "Third", "Blah blah.", Timestamp::new(2), &[]).unwrap();
-
        issues
-
            .store
-
            .create("Create issue", contents, &signer)
-
            .unwrap();
-

-
        let issues = issues.all().unwrap();
-
        assert_eq!(issues.len(), 3);
-

-
        // Issues are sorted by timestamp.
-
        assert_eq!(issues[0].1.title(), "First");
-
        assert_eq!(issues[1].1.title(), "Second");
-
        assert_eq!(issues[2].1.title(), "Third");
-
    }
-
}
modified radicle/src/cob/automerge/patch.rs
@@ -33,7 +33,7 @@ impl TryFrom<Document<'_>> for Patch {
        let target = doc.get(&obj_id, "target")?;
        let timestamp = doc.get(&obj_id, "timestamp")?;
        let revisions = doc.list(&obj_id, "revisions", lookup::revision)?;
-
        let labels: HashSet<Label> = doc.keys(&obj_id, "labels")?;
+
        let labels: HashSet<Tag> = doc.keys(&obj_id, "labels")?;
        let revisions =
            NonEmpty::from_vec(revisions).ok_or(DocumentError::EmptyList("revisions"))?;
        let author: Author = Author::new(author);
@@ -81,7 +81,7 @@ impl<'a> PatchStore<'a> {
        target: MergeTarget,
        base: impl Into<git::Oid>,
        oid: impl Into<git::Oid>,
-
        labels: &[Label],
+
        labels: &[Tag],
        signer: &G,
    ) -> Result<PatchId, Error> {
        let author = self.author();
@@ -464,7 +464,7 @@ mod events {
        revision: &Revision,
        target: MergeTarget,
        timestamp: Timestamp,
-
        labels: &[Label],
+
        labels: &[Tag],
    ) -> Result<Contents, TransactionError> {
        let title = title.trim();
        if title.is_empty() {
modified radicle/src/cob/automerge/store.rs
@@ -9,7 +9,7 @@ use crate::cob;
use crate::cob::automerge::doc::DocumentError;
use crate::cob::automerge::shared::FromHistory;
use crate::cob::automerge::transaction::TransactionError;
-
use crate::cob::automerge::{issue, label, patch};
+
use crate::cob::automerge::{label, patch};
use crate::cob::common::Author;
use crate::cob::CollaborativeObject;
use crate::cob::{Contents, Create, HistoryType, ObjectId, TypeName, Update};
@@ -77,16 +77,6 @@ impl<'a> Store<'a, ()> {
        })
    }

-
    /// Return an issues store from this generic store.
-
    pub fn issues(&self) -> issue::IssueStore<'_> {
-
        issue::IssueStore::new(Store {
-
            whoami: self.whoami,
-
            project: self.project.clone(),
-
            raw: self.raw,
-
            witness: PhantomData,
-
        })
-
    }
-

    /// Return a labels store from this generic store.
    pub fn labels(&self) -> label::LabelStore<'_> {
        label::LabelStore::new(Store {
modified radicle/src/cob/common.rs
@@ -95,21 +95,21 @@ impl FromStr for Reaction {
}

#[derive(thiserror::Error, Debug)]
-
pub enum LabelError {
-
    #[error("invalid label name: `{0}`")]
+
pub enum TagError {
+
    #[error("invalid tag name: `{0}`")]
    InvalidName(String),
}

-
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
+
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)]
#[serde(transparent)]
-
pub struct Label(String);
+
pub struct Tag(String);

-
impl Label {
-
    pub fn new(name: impl Into<String>) -> Result<Self, LabelError> {
+
impl Tag {
+
    pub fn new(name: impl Into<String>) -> Result<Self, TagError> {
        let name = name.into();

        if name.chars().any(|c| c.is_whitespace()) || name.is_empty() {
-
            return Err(LabelError::InvalidName(name));
+
            return Err(TagError::InvalidName(name));
        }
        Ok(Self(name))
    }
@@ -119,16 +119,16 @@ impl Label {
    }
}

-
impl FromStr for Label {
-
    type Err = LabelError;
+
impl FromStr for Tag {
+
    type Err = TagError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::new(s)
    }
}

-
impl From<Label> for String {
-
    fn from(Label(name): Label) -> Self {
+
impl From<Tag> for String {
+
    fn from(Tag(name): Tag) -> Self {
        name
    }
}
modified radicle/src/cob/issue.rs
@@ -1,11 +1,20 @@
-
use std::collections::{HashMap, HashSet};
+
use std::ops::{ControlFlow, Deref};
use std::str::FromStr;

use once_cell::sync::Lazy;
+
use radicle_crdt as crdt;
+
use radicle_crdt::{ChangeId, LClock, LWWReg, Max, Semilattice};
use serde::{Deserialize, Serialize};
+
use thiserror::Error;

-
use crate::cob::common::*;
-
use crate::cob::{ObjectId, Timestamp, TypeName};
+
use crate::cob::common::{Author, Reaction, Tag};
+
use crate::cob::thread;
+
use crate::cob::thread::{CommentId, Thread};
+
use crate::cob::{store, ObjectId, TypeName};
+
use crate::crypto::{PublicKey, Signer};
+
use crate::storage::git as storage;
+

+
pub type Change = crdt::Change<Action>;

/// Type name of an issue.
pub static TYPENAME: Lazy<TypeName> =
@@ -14,75 +23,589 @@ pub static TYPENAME: Lazy<TypeName> =
/// Identifier for an issue.
pub type IssueId = ObjectId;

+
/// Error updating or creating issues.
+
#[derive(Error, Debug)]
+
pub enum Error {
+
    #[error("apply failed")]
+
    Apply,
+
    #[error("store: {0}")]
+
    Store(#[from] store::Error),
+
}
+

/// Reason why an issue was closed.
-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+
#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CloseReason {
-
    Solved,
    Other,
+
    Solved,
}

/// Issue state.
-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+
#[derive(Debug, Default, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", tag = "status")]
-
pub enum State {
-
    /// The issue is open.
-
    Open,
+
pub enum Status {
    /// The issue is closed.
    Closed { reason: CloseReason },
+
    /// The issue is open.
+
    #[default]
+
    Open,
}

-
impl State {
+
impl std::fmt::Display for Status {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Self::Closed { .. } => write!(f, "closed"),
+
            Self::Open { .. } => write!(f, "open"),
+
        }
+
    }
+
}
+

+
impl Status {
    pub fn lifecycle_message(self) -> String {
        match self {
-
            State::Open => "Open issue".to_owned(),
-
            State::Closed { .. } => "Close issue".to_owned(),
+
            Status::Open => "Open issue".to_owned(),
+
            Status::Closed { .. } => "Close issue".to_owned(),
        }
    }
}

-
/// An issue or "ticket".
-
#[derive(Debug, Clone, Serialize, Deserialize)]
+
/// Issue state. Accumulates [`Action`].
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Issue {
-
    pub author: Author,
-
    pub title: String,
-
    pub state: State,
-
    pub comment: Comment,
-
    pub discussion: Discussion,
-
    pub labels: HashSet<Label>,
-
    pub timestamp: Timestamp,
+
    // TODO(cloudhead): Title should bias towards shorter strings.
+
    title: LWWReg<Max<String>, LClock>,
+
    status: LWWReg<Max<Status>, LClock>,
+
    thread: Thread,
+
    clock: LClock,
}

-
impl Issue {
-
    pub fn author(&self) -> &Author {
-
        &self.author
+
impl Semilattice for Issue {
+
    fn merge(&mut self, other: Self) {
+
        self.title.merge(other.title);
+
        self.status.merge(other.status);
+
        self.thread.merge(other.thread);
    }
+
}

+
impl Default for Issue {
+
    fn default() -> Self {
+
        Self {
+
            title: Max::from(String::default()).into(),
+
            status: Max::from(Status::default()).into(),
+
            thread: Thread::default(),
+
            clock: LClock::default(),
+
        }
+
    }
+
}
+

+
impl store::FromHistory for Issue {
+
    fn type_name() -> &'static TypeName {
+
        &*TYPENAME
+
    }
+

+
    fn from_history(history: &radicle_cob::History) -> Result<Self, store::Error> {
+
        Ok(history.traverse(Self::default(), |mut acc, entry| {
+
            if let Ok(change) = Change::decode(entry.contents()) {
+
                if let Err(err) = acc.apply(change) {
+
                    log::warn!("Error applying change to issue state: {err}");
+
                    return ControlFlow::Break(acc);
+
                }
+
            } else {
+
                return ControlFlow::Break(acc);
+
            }
+
            ControlFlow::Continue(acc)
+
        }))
+
    }
+
}
+

+
impl Issue {
    pub fn title(&self) -> &str {
-
        &self.title
+
        self.title.get().as_str()
+
    }
+

+
    pub fn status(&self) -> &Status {
+
        self.status.get()
+
    }
+

+
    pub fn tags(&self) -> impl Iterator<Item = &Tag> {
+
        self.thread.tags()
+
    }
+

+
    pub fn author(&self) -> Option<Author> {
+
        self.thread
+
            .comments()
+
            .next()
+
            .map(|((_, pk), _)| Author::new(*pk))
+
    }
+

+
    pub fn description(&self) -> Option<&str> {
+
        self.thread.comments().next().map(|(_, c)| c.body.as_str())
+
    }
+

+
    pub fn comments(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment)> {
+
        self.thread.comments().map(|(id, comment)| (id, comment))
+
    }
+

+
    pub fn clock(&self) -> &LClock {
+
        &self.clock
+
    }
+

+
    pub fn timeline(&self) -> Vec<Action> {
+
        todo!();
+
    }
+

+
    pub fn apply(&mut self, change: Change) -> Result<(), Error> {
+
        match change.action {
+
            Action::Title { title } => {
+
                self.title.set(title, change.clock);
+
            }
+
            Action::Lifecycle { status } => {
+
                self.status.set(status, change.clock);
+
            }
+
            Action::Thread { action } => {
+
                self.thread.apply([crdt::Change {
+
                    action,
+
                    author: change.author,
+
                    clock: change.clock,
+
                }]);
+
            }
+
        }
+
        self.clock.merge(change.clock);
+

+
        Ok(())
+
    }
+
}
+

+
impl Deref for Issue {
+
    type Target = Thread;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.thread
+
    }
+
}
+

+
pub struct IssueMut<'a, 'g> {
+
    id: ObjectId,
+
    clock: LClock,
+
    issue: Issue,
+
    store: &'g mut Issues<'a>,
+
}
+

+
impl<'a, 'g> IssueMut<'a, 'g> {
+
    /// Get the internal logical clock.
+
    pub fn clock(&self) -> &LClock {
+
        &self.clock
+
    }
+

+
    /// Lifecycle an issue.
+
    pub fn lifecycle<G: Signer>(&mut self, status: Status, signer: &G) -> Result<ChangeId, Error> {
+
        let clock = self.clock.tick();
+
        let change = Change {
+
            action: Action::Lifecycle { status },
+
            author: *signer.public_key(),
+
            clock,
+
        };
+
        self.apply("Lifecycle", change, signer)
+
    }
+

+
    /// Comment on an issue.
+
    pub fn comment<G: Signer, S: Into<String>>(
+
        &mut self,
+
        body: S,
+
        signer: &G,
+
    ) -> Result<CommentId, Error> {
+
        let author = *signer.public_key();
+
        let clock = self.clock.tick();
+
        let body = body.into();
+
        let comment = thread::Comment::new(body, None);
+
        let change = Change {
+
            action: Action::from(thread::Action::Comment { comment }),
+
            author,
+
            clock,
+
        };
+
        self.apply("Comment", change, signer)
+
    }
+

+
    /// Tag an issue.
+
    pub fn tag<G: Signer>(
+
        &mut self,
+
        tags: impl IntoIterator<Item = Tag>,
+
        signer: &G,
+
    ) -> Result<ChangeId, Error> {
+
        let author = *signer.public_key();
+
        let clock = self.clock.tick();
+
        let tags = tags.into_iter().collect::<Vec<_>>();
+
        let change = Change {
+
            author,
+
            action: Action::Thread {
+
                action: thread::Action::Tag { tags },
+
            },
+
            clock,
+
        };
+
        self.apply("Tag", change, signer)
+
    }
+

+
    /// Reply to on an issue comment.
+
    pub fn reply<G: Signer, S: Into<String>>(
+
        &mut self,
+
        parent: CommentId,
+
        body: S,
+
        signer: &G,
+
    ) -> Result<ChangeId, Error> {
+
        let author = *signer.public_key();
+
        let clock = self.clock.tick();
+
        let body = body.into();
+

+
        assert!(self.thread.comment(&parent).is_some());
+

+
        let comment = thread::Comment::new(body, Some(parent));
+
        let change = Change {
+
            action: Action::from(thread::Action::Comment { comment }),
+
            author,
+
            clock,
+
        };
+
        self.apply("Reply", change, signer)
+
    }
+

+
    /// React to an issue comment.
+
    pub fn react<G: Signer>(
+
        &mut self,
+
        to: CommentId,
+
        reaction: Reaction,
+
        signer: &G,
+
    ) -> Result<ChangeId, Error> {
+
        let author = *signer.public_key();
+
        let clock = self.clock.tick();
+
        let change = Change {
+
            action: Action::Thread {
+
                action: thread::Action::React {
+
                    to,
+
                    reaction,
+
                    active: true,
+
                },
+
            },
+
            author,
+
            clock,
+
        };
+
        self.apply("React", change, signer)
    }

-
    pub fn state(&self) -> State {
-
        self.state
+
    /// Apply a change to the issue.
+
    pub fn apply<G: Signer>(
+
        &mut self,
+
        msg: &'static str,
+
        change: Change,
+
        signer: &G,
+
    ) -> Result<ChangeId, Error> {
+
        let id = change.id();
+

+
        self.issue.apply(change.clone())?;
+
        self.store
+
            .update(self.id, msg, change, signer)
+
            .map_err(|e| Error::Store(store::Error::from(e)))?;
+

+
        Ok(id)
    }
+
}

-
    pub fn description(&self) -> &str {
-
        &self.comment.body
+
impl<'a, 'g> Deref for IssueMut<'a, 'g> {
+
    type Target = Issue;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.issue
    }
+
}

-
    pub fn reactions(&self) -> &HashMap<Reaction, usize> {
-
        &self.comment.reactions
+
pub struct Issues<'a> {
+
    raw: store::Store<'a, Issue>,
+
}
+

+
impl<'a> Deref for Issues<'a> {
+
    type Target = store::Store<'a, Issue>;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.raw
    }
+
}

-
    pub fn comments(&self) -> &[Comment<Replies>] {
-
        &self.discussion
+
impl<'a> Issues<'a> {
+
    /// Open an issues store.
+
    pub fn open(
+
        whoami: PublicKey,
+
        repository: &'a storage::Repository,
+
    ) -> Result<Self, store::Error> {
+
        let raw = store::Store::open(whoami, repository)?;
+

+
        Ok(Self { raw })
+
    }
+

+
    /// Get an issue.
+
    pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, store::Error> {
+
        self.raw.get(id)
+
    }
+

+
    /// Get an issue mutably.
+
    pub fn get_mut<'g>(&'g mut self, id: &ObjectId) -> Result<IssueMut<'a, 'g>, store::Error> {
+
        let issue = self
+
            .raw
+
            .get(id)?
+
            .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
+

+
        Ok(IssueMut {
+
            id: *id,
+
            clock: issue.clock,
+
            issue,
+
            store: self,
+
        })
    }

-
    pub fn labels(&self) -> &HashSet<Label> {
-
        &self.labels
+
    /// Create a new issue.
+
    pub fn create<'g, G: Signer>(
+
        &'g mut self,
+
        title: impl Into<String>,
+
        description: impl Into<String>,
+
        tags: &[Tag],
+
        signer: &G,
+
    ) -> Result<IssueMut<'a, 'g>, Error> {
+
        let title = title.into();
+
        let description = description.into();
+
        let change = Change {
+
            author: self.author().id,
+
            action: Action::Title { title },
+
            clock: LClock::default(),
+
        };
+
        let (id, issue): (_, Issue) = self.raw.create("Create issue", change, signer)?;
+
        let mut issue = IssueMut {
+
            id,
+
            clock: issue.clock,
+
            issue,
+
            store: self,
+
        };
+

+
        issue.comment(description, signer)?;
+
        issue.tag(tags.to_owned(), signer)?;
+

+
        Ok(issue)
    }

-
    pub fn timestamp(&self) -> Timestamp {
-
        self.timestamp
+
    /// Remove an issue.
+
    pub fn remove(&self, id: &ObjectId) -> Result<(), store::Error> {
+
        self.raw.remove(id)
+
    }
+
}
+

+
/// Issue operation.
+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
pub enum Action {
+
    Title { title: String },
+
    Lifecycle { status: Status },
+
    Thread { action: thread::Action },
+
}
+

+
impl From<thread::Action> for Action {
+
    fn from(action: thread::Action) -> Self {
+
        Self::Thread { action }
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::*;
+
    use crate::cob::Reaction;
+
    use crate::test;
+

+
    #[test]
+
    fn test_ordering() {
+
        assert!(CloseReason::Solved > CloseReason::Other);
+
        assert!(
+
            Status::Open
+
                > Status::Closed {
+
                    reason: CloseReason::Solved
+
                }
+
        );
+
    }
+

+
    #[test]
+
    fn test_issue_create_and_get() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
+
        let created = issues
+
            .create("My first issue", "Blah blah blah.", &[], &signer)
+
            .unwrap();
+
        let (id, created) = (created.id, created.issue);
+
        let issue = issues.get(&id).unwrap().unwrap();
+

+
        assert_eq!(issue, created);
+
        assert_eq!(issue.title(), "My first issue");
+
        assert_eq!(issue.author(), Some(issues.author()));
+
        assert_eq!(issue.description(), Some("Blah blah blah."));
+
        assert_eq!(issue.comments().count(), 1);
+
        assert_eq!(issue.status(), &Status::Open);
+
    }
+

+
    #[test]
+
    fn test_issue_create_and_change_state() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
+
        let mut issue = issues
+
            .create("My first issue", "Blah blah blah.", &[], &signer)
+
            .unwrap();
+

+
        issue
+
            .lifecycle(
+
                Status::Closed {
+
                    reason: CloseReason::Other,
+
                },
+
                &signer,
+
            )
+
            .unwrap();
+

+
        let id = issue.id;
+
        let mut issue = issues.get_mut(&id).unwrap();
+
        assert_eq!(
+
            *issue.status(),
+
            Status::Closed {
+
                reason: CloseReason::Other
+
            }
+
        );
+

+
        issue.lifecycle(Status::Open, &signer).unwrap();
+
        let issue = issues.get(&id).unwrap().unwrap();
+
        assert_eq!(*issue.status(), Status::Open);
+
    }
+

+
    #[test]
+
    fn test_issue_react() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
+
        let mut issue = issues
+
            .create("My first issue", "Blah blah blah.", &[], &signer)
+
            .unwrap();
+

+
        let comment = (LClock::default(), *signer.public_key());
+
        let reaction = Reaction::new('🥳').unwrap();
+
        issue.react(comment, reaction, &signer).unwrap();
+

+
        let id = issue.id;
+
        let issue = issues.get(&id).unwrap().unwrap();
+
        let (_, r) = issue.reactions(&comment).next().unwrap();
+

+
        assert_eq!(r, &reaction);
+

+
        // TODO: Test multiple reactions from same author and different authors
+
    }
+

+
    #[test]
+
    fn test_issue_reply() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
+
        let mut issue = issues
+
            .create("My first issue", "Blah blah blah.", &[], &signer)
+
            .unwrap();
+
        let comment = issue.comment("Ho ho ho.", &signer).unwrap();
+

+
        issue.reply(comment, "Hi hi hi.", &signer).unwrap();
+
        issue.reply(comment, "Ha ha ha.", &signer).unwrap();
+

+
        let id = issue.id;
+
        let issue = issues.get(&id).unwrap().unwrap();
+
        let (_, reply1) = &issue.replies(&comment).nth(0).unwrap();
+
        let (_, reply2) = &issue.replies(&comment).nth(1).unwrap();
+

+
        assert_eq!(reply1.body, "Hi hi hi.");
+
        assert_eq!(reply2.body, "Ha ha ha.");
+
    }
+

+
    #[test]
+
    fn test_issue_tag() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
+
        let mut issue = issues
+
            .create("My first issue", "Blah blah blah.", &[], &signer)
+
            .unwrap();
+

+
        let bug_tag = Tag::new("bug").unwrap();
+
        let wontfix_tag = Tag::new("wontfix").unwrap();
+

+
        issue.tag([bug_tag.clone()], &signer).unwrap();
+
        issue.tag([wontfix_tag.clone()], &signer).unwrap();
+

+
        let id = issue.id;
+
        let issue = issues.get(&id).unwrap().unwrap();
+
        let tags = issue.tags().cloned().collect::<Vec<_>>();
+

+
        assert!(tags.contains(&bug_tag));
+
        assert!(tags.contains(&wontfix_tag));
+
    }
+

+
    #[test]
+
    fn test_issue_comment() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        let author = *signer.public_key();
+
        let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
+
        let mut issue = issues
+
            .create("My first issue", "Blah blah blah.", &[], &signer)
+
            .unwrap();
+

+
        issue.comment("Ho ho ho.", &signer).unwrap();
+
        issue.comment("Ha ha ha.", &signer).unwrap();
+

+
        let id = issue.id;
+
        let issue = issues.get(&id).unwrap().unwrap();
+
        let ((_, a0), c0) = &issue.comments().nth(0).unwrap();
+
        let ((_, a1), c1) = &issue.comments().nth(1).unwrap();
+
        let ((_, a2), c2) = &issue.comments().nth(2).unwrap();
+

+
        assert_eq!(&c0.body, "Blah blah blah.");
+
        assert_eq!(a0, &author);
+
        assert_eq!(&c1.body, "Ho ho ho.");
+
        assert_eq!(a1, &author);
+
        assert_eq!(&c2.body, "Ha ha ha.");
+
        assert_eq!(a2, &author);
+
    }
+

+
    #[test]
+
    fn test_issue_state_serde() {
+
        assert_eq!(
+
            serde_json::to_value(Status::Open).unwrap(),
+
            serde_json::json!({ "status": "open" })
+
        );
+

+
        assert_eq!(
+
            serde_json::to_value(Status::Closed {
+
                reason: CloseReason::Solved
+
            })
+
            .unwrap(),
+
            serde_json::json!({ "status": "closed", "reason": "solved" })
+
        );
+
    }
+

+
    #[test]
+
    fn test_issue_all() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
+

+
        issues.create("First", "Blah", &[], &signer).unwrap();
+
        issues.create("Second", "Blah", &[], &signer).unwrap();
+
        issues.create("Third", "Blah", &[], &signer).unwrap();
+

+
        let issues = issues
+
            .all()
+
            .unwrap()
+
            .collect::<Result<Vec<_>, _>>()
+
            .unwrap();
+

+
        assert_eq!(issues.len(), 3);
+

+
        issues.iter().find(|(_, i)| i.title() == "First").unwrap();
+
        issues.iter().find(|(_, i)| i.title() == "Second").unwrap();
+
        issues.iter().find(|(_, i)| i.title() == "Third").unwrap();
    }
}
modified radicle/src/cob/patch.rs
@@ -51,7 +51,7 @@ where
    /// Target this patch is meant to be merged in.
    pub target: MergeTarget,
    /// Labels associated with the patch.
-
    pub labels: HashSet<Label>,
+
    pub labels: HashSet<Tag>,
    /// List of patch revisions. The initial changeset is part of the
    /// first revision.
    pub revisions: NonEmpty<Revision<T>>,
modified radicle/src/cob/store.rs
@@ -2,10 +2,13 @@
#![allow(clippy::large_enum_variant)]
use std::marker::PhantomData;

+
use radicle_crdt::Change;
+
use serde::Serialize;
+

use crate::cob;
use crate::cob::common::Author;
use crate::cob::CollaborativeObject;
-
use crate::cob::{Contents, Create, History, HistoryType, ObjectId, TypeName, Update};
+
use crate::cob::{Create, History, HistoryType, ObjectId, TypeName, Update};
use crate::crypto::PublicKey;
use crate::git;
use crate::identity::project;
@@ -32,7 +35,7 @@ pub enum Error {
    Retrieve(#[from] cob::error::Retrieve),
    #[error(transparent)]
    Identity(#[from] project::IdentityError),
-
    #[error("object `{1}`of type `{0}` was not found")]
+
    #[error("object `{1}` of type `{0}` was not found")]
    NotFound(TypeName, ObjectId),
}

@@ -62,9 +65,7 @@ impl<'a, T> Store<'a, T> {
            witness: PhantomData,
        })
    }
-
}

-
impl<'a, T> Store<'a, T> {
    /// Get this store's author.
    pub fn author(&self) -> Author {
        Author::new(self.whoami)
@@ -78,11 +79,11 @@ impl<'a, T> Store<'a, T> {

impl<'a, T: FromHistory> Store<'a, T> {
    /// Update an object.
-
    pub fn update<G: Signer>(
+
    pub fn update<A: Serialize, G: Signer>(
        &self,
        object_id: ObjectId,
        message: &'static str,
-
        changes: Contents,
+
        change: Change<A>,
        signer: &G,
    ) -> Result<CollaborativeObject, cob::error::Update> {
        cob::update(
@@ -95,19 +96,19 @@ impl<'a, T: FromHistory> Store<'a, T> {
                history_type: HistoryType::default(),
                typename: T::type_name().clone(),
                message: message.to_owned(),
-
                changes,
+
                changes: change.encode(),
            },
        )
    }

    /// Create an object.
-
    pub fn create<G: Signer>(
+
    pub fn create<A: Serialize, G: Signer>(
        &self,
        message: &'static str,
-
        contents: Contents,
+
        change: Change<A>,
        signer: &G,
-
    ) -> Result<CollaborativeObject, cob::error::Create> {
-
        cob::create(
+
    ) -> Result<(ObjectId, T), Error> {
+
        let cob = cob::create(
            self.raw,
            signer,
            &self.project,
@@ -116,9 +117,12 @@ impl<'a, T: FromHistory> Store<'a, T> {
                history_type: HistoryType::default(),
                typename: T::type_name().clone(),
                message: message.to_owned(),
-
                contents,
+
                contents: change.encode(),
            },
-
        )
+
        )?;
+
        let object = T::from_history(cob.history())?;
+

+
        Ok((*cob.id(), object))
    }

    /// Get an object.
@@ -126,24 +130,24 @@ impl<'a, T: FromHistory> Store<'a, T> {
        let cob = cob::get(self.raw, T::type_name(), id)?;

        if let Some(cob) = cob {
-
            let history = cob.history();
-
            let obj = T::from_history(history)?;
-

+
            let obj = T::from_history(cob.history())?;
            Ok(Some(obj))
        } else {
            Ok(None)
        }
    }

-
    /// List objects.
-
    pub fn list(&self) -> Result<Vec<(ObjectId, T)>, Error> {
+
    /// Return all objects.
+
    pub fn all(&self) -> Result<impl Iterator<Item = Result<(ObjectId, T), Error>>, Error> {
        let raw = cob::list(self.raw, T::type_name())?;

-
        raw.into_iter()
-
            .map(|o| {
-
                let obj = T::from_history(o.history())?;
-
                Ok::<_, Error>((*o.id(), obj))
-
            })
-
            .collect()
+
        Ok(raw.into_iter().map(|o| {
+
            let obj = T::from_history(o.history())?;
+
            Ok((*o.id(), obj))
+
        }))
+
    }
+

+
    pub fn remove(&self, _id: &ObjectId) -> Result<(), Error> {
+
        todo!();
    }
}
modified radicle/src/cob/thread.rs
@@ -7,7 +7,7 @@ use once_cell::sync::Lazy;
use radicle_crdt as crdt;
use serde::{Deserialize, Serialize};

-
use crate::cob::common::Reaction;
+
use crate::cob::common::{Reaction, Tag};
use crate::cob::store;
use crate::cob::{History, TypeName};
use crate::crypto::{PublicKey, Signer};
@@ -22,18 +22,18 @@ use crdt::{Change, ChangeId, Semilattice};
pub static TYPENAME: Lazy<TypeName> =
    Lazy::new(|| FromStr::from_str("xyz.radicle.thread").expect("type name is valid"));

-
/// Identifies a tag.
-
pub type TagId = String;
/// Alias for `Author`.
pub type ActorId = PublicKey;
+
/// Identifies a comment.
+
pub type CommentId = ChangeId;

/// A comment on a discussion thread.
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Comment {
    /// The comment body.
-
    body: String,
+
    pub body: String,
    /// Thread or comment this is a reply to.
-
    reply_to: Option<ChangeId>,
+
    pub reply_to: Option<ChangeId>,
}

impl Comment {
@@ -56,10 +56,10 @@ pub enum Action {
    Comment { comment: Comment },
    /// Redact a change. Not all changes can be redacted.
    Redact { id: ChangeId },
-
    /// Add a tag to the thread.
-
    Tag { tag: TagId },
-
    /// Remove a tag from the thread.
-
    Untag { tag: TagId },
+
    /// Add tags to the thread.
+
    Tag { tags: Vec<Tag> },
+
    /// Remove tags from the thread.
+
    Untag { tags: Vec<Tag> },
    /// React to a change.
    React {
        to: ChangeId,
@@ -72,11 +72,11 @@ pub enum Action {
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Thread {
    /// The comments under the thread.
-
    comments: BTreeMap<ChangeId, Redactable<Comment>>,
+
    comments: BTreeMap<CommentId, Redactable<Comment>>,
    /// Associated tags.
-
    tags: BTreeMap<TagId, LWWReg<bool, LClock>>,
+
    tags: BTreeMap<Tag, LWWReg<bool, LClock>>,
    /// Reactions to changes.
-
    reactions: BTreeMap<ChangeId, LWWSet<(ActorId, Reaction), LClock>>,
+
    reactions: BTreeMap<CommentId, LWWSet<(ActorId, Reaction), LClock>>,
}

impl store::FromHistory for Thread {
@@ -105,7 +105,7 @@ impl Semilattice for Thread {
}

impl Deref for Thread {
-
    type Target = BTreeMap<ChangeId, Redactable<Comment>>;
+
    type Target = BTreeMap<CommentId, Redactable<Comment>>;

    fn deref(&self) -> &Self::Target {
        &self.comments
@@ -117,6 +117,39 @@ impl Thread {
        self.comments.clear();
    }

+
    pub fn comment(&self, id: &CommentId) -> Option<&Comment> {
+
        if let Some(Redactable::Present(comment)) = self.comments.get(id) {
+
            Some(comment)
+
        } else {
+
            None
+
        }
+
    }
+

+
    pub fn replies<'a>(
+
        &'a self,
+
        to: &'a CommentId,
+
    ) -> impl Iterator<Item = (&CommentId, &Comment)> {
+
        self.comments().filter_map(move |(id, c)| {
+
            if let Some(parent) = &c.reply_to {
+
                if parent == to {
+
                    return Some((id, c));
+
                }
+
            }
+
            None
+
        })
+
    }
+

+
    pub fn reactions<'a>(
+
        &'a self,
+
        to: &'a CommentId,
+
    ) -> impl Iterator<Item = (&ActorId, &Reaction)> {
+
        self.reactions
+
            .get(to)
+
            .into_iter()
+
            .flat_map(move |rs| rs.iter())
+
            .map(|(a, r)| (a, r))
+
    }
+

    pub fn apply(&mut self, changes: impl IntoIterator<Item = Change<Action>>) {
        for change in changes.into_iter() {
            let id = change.id();
@@ -140,17 +173,21 @@ impl Thread {
                        .and_modify(|e| e.merge(Redactable::Redacted))
                        .or_insert(Redactable::Redacted);
                }
-
                Action::Tag { tag } => {
-
                    self.tags
-
                        .entry(tag)
-
                        .and_modify(|r| r.set(true, change.clock))
-
                        .or_insert_with(|| LWWReg::new(true, change.clock));
+
                Action::Tag { tags } => {
+
                    for tag in tags {
+
                        self.tags
+
                            .entry(tag)
+
                            .and_modify(|r| r.set(true, change.clock))
+
                            .or_insert_with(|| LWWReg::new(true, change.clock));
+
                    }
                }
-
                Action::Untag { tag } => {
-
                    self.tags
-
                        .entry(tag)
-
                        .and_modify(|r| r.set(false, change.clock))
-
                        .or_insert_with(|| LWWReg::new(false, change.clock));
+
                Action::Untag { tags } => {
+
                    for tag in tags {
+
                        self.tags
+
                            .entry(tag)
+
                            .and_modify(|r| r.set(false, change.clock))
+
                            .or_insert_with(|| LWWReg::new(false, change.clock));
+
                    }
                }
                Action::React {
                    to,
@@ -180,7 +217,7 @@ impl Thread {
        }
    }

-
    pub fn comments(&self) -> impl Iterator<Item = (&ChangeId, &Comment)> + '_ {
+
    pub fn comments(&self) -> impl Iterator<Item = (&CommentId, &Comment)> + '_ {
        self.comments.iter().filter_map(|(id, comment)| {
            if let Redactable::Present(c) = comment {
                Some((id, c))
@@ -190,7 +227,7 @@ impl Thread {
        })
    }

-
    pub fn tags(&self) -> impl Iterator<Item = &TagId> + '_ {
+
    pub fn tags(&self) -> impl Iterator<Item = &Tag> + '_ {
        self.tags
            .iter()
            .filter_map(|(tag, r)| if *r.get() { Some(tag) } else { None })
@@ -230,13 +267,13 @@ impl<G: Signer> Actor<G> {
    }

    /// Add a tag.
-
    pub fn tag(&mut self, tag: TagId) -> Change<Action> {
-
        self.change(Action::Tag { tag })
+
    pub fn tag(&mut self, tag: Tag) -> Change<Action> {
+
        self.change(Action::Tag { tags: vec![tag] })
    }

    /// Remove a tag.
-
    pub fn untag(&mut self, tag: TagId) -> Change<Action> {
-
        self.change(Action::Untag { tag })
+
    pub fn untag(&mut self, tag: Tag) -> Change<Action> {
+
        self.change(Action::Untag { tags: vec![tag] })
    }

    /// Create a new redaction.
@@ -294,7 +331,7 @@ mod tests {
        fn arbitrary(g: &mut quickcheck::Gen) -> Self {
            let rng = fastrand::Rng::with_seed(u64::arbitrary(g));
            let gen =
-
                WeightedGenerator::<Action, (Vec<TagId>, Vec<Change<Action>>)>::new(rng.clone())
+
                WeightedGenerator::<Action, (Vec<Tag>, Vec<Change<Action>>)>::new(rng.clone())
                    .variant(2, |_, rng| {
                        Some(Action::Comment {
                            comment: Comment {
@@ -327,19 +364,20 @@ mod tests {
                            let tag = iter::repeat_with(|| rng.alphabetic())
                                .take(8)
                                .collect::<String>();
+
                            let tag = Tag::new(tag).unwrap();
                            tags.push(tag.clone());
                            tag
                        } else {
                            tags[rng.usize(..tags.len())].clone()
                        };
-
                        Some(Action::Tag { tag })
+
                        Some(Action::Tag { tags: vec![tag] })
                    })
                    .variant(2, |(tags, _), rng| {
                        if tags.is_empty() {
                            return None;
                        }
                        let tag = tags[rng.usize(..tags.len())].clone();
-
                        Some(Action::Untag { tag })
+
                        Some(Action::Untag { tags: vec![tag] })
                    });

            let mut changes = Vec::new();
@@ -381,14 +419,12 @@ mod tests {
        let mut expected = Thread::default();
        expected.apply([a1.clone(), a2.clone()]);

-
        let created = store
-
            .create("Thread created", a1.encode(), &alice.signer)
-
            .unwrap();
+
        let (id, _) = store.create("Thread created", a1, &alice.signer).unwrap();
        store
-
            .update(*created.id(), "Thread updated", a2.encode(), &alice.signer)
+
            .update(id, "Thread updated", a2, &alice.signer)
            .unwrap();

-
        let actual = store.get(created.id()).unwrap().unwrap();
+
        let actual = store.get(&id).unwrap().unwrap();

        assert_eq!(actual, expected);
    }
modified radicle/src/lib.rs
@@ -1,5 +1,6 @@
#![allow(clippy::match_like_matches_macro)]
#![allow(clippy::explicit_auto_deref)] // TODO: This can be removed when the clippy bugs are fixed
+
#![allow(clippy::iter_nth_zero)]
#![cfg_attr(not(test), warn(clippy::unwrap_used))]

pub extern crate radicle_crypto as crypto;