Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add `Patch`, `Issue`, `Label` COBs
Alexis Sellier committed 3 years ago
commit 12988da089bfd916e7eb6e11563a970a13825603
parent 08811b4b875530f11186763a11cc23da855c9973
13 files changed +3133 -1
modified Cargo.lock
@@ -63,6 +63,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"

[[package]]
+
name = "automerge"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3e97999332403b0685c203d4937730f72777829c8b6ed92d6f1ccba017f38e61"
+
dependencies = [
+
 "flate2",
+
 "fxhash",
+
 "hex",
+
 "itertools",
+
 "leb128",
+
 "serde",
+
 "sha2 0.10.6",
+
 "smol_str",
+
 "thiserror",
+
 "tinyvec",
+
 "tracing",
+
 "uuid 0.8.2",
+
]
+

+
[[package]]
name = "axum"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -551,6 +571,12 @@ dependencies = [
]

[[package]]
+
name = "either"
+
version = "1.8.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797"
+

+
[[package]]
name = "elliptic-curve"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -665,6 +691,15 @@ dependencies = [
]

[[package]]
+
name = "fxhash"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
+
dependencies = [
+
 "byteorder",
+
]
+

+
[[package]]
name = "generic-array"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -808,6 +843,12 @@ dependencies = [
]

[[package]]
+
name = "hex"
+
version = "0.4.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+

+
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -955,6 +996,15 @@ dependencies = [
]

[[package]]
+
name = "itertools"
+
version = "0.10.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+
dependencies = [
+
 "either",
+
]
+

+
[[package]]
name = "itoa"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -999,6 +1049,12 @@ dependencies = [
]

[[package]]
+
name = "leb128"
+
version = "0.2.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
+

+
[[package]]
name = "lexopt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1475,6 +1531,7 @@ dependencies = [
name = "radicle"
version = "0.2.0"
dependencies = [
+
 "automerge",
 "base64",
 "byteorder",
 "crossbeam-channel",
@@ -1501,6 +1558,7 @@ dependencies = [
 "sqlite",
 "tempfile",
 "thiserror",
+
 "uuid 1.2.1",
 "zeroize",
]

@@ -1681,6 +1739,7 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
+
 "libc",
 "rand_chacha 0.3.1",
 "rand_core 0.6.4",
]
@@ -1950,6 +2009,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"

[[package]]
+
name = "smol_str"
+
version = "0.1.23"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7475118a28b7e3a2e157ce0131ba8c5526ea96e90ee601d9f6bb2e286a35ab44"
+
dependencies = [
+
 "serde",
+
]
+

+
[[package]]
name = "socket2"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2412,6 +2480,27 @@ dependencies = [
]

[[package]]
+
name = "uuid"
+
version = "0.8.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
+
dependencies = [
+
 "getrandom 0.2.8",
+
 "serde",
+
]
+

+
[[package]]
+
name = "uuid"
+
version = "1.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "feb41e78f93363bb2df8b0e86a2ca30eed7806ea16ea0c790d757cf93f79be83"
+
dependencies = [
+
 "getrandom 0.2.8",
+
 "rand 0.8.5",
+
 "serde",
+
]
+

+
[[package]]
name = "valuable"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified radicle/Cargo.toml
@@ -11,6 +11,7 @@ test = ["quickcheck"]
sql = ["sqlite"]

[dependencies]
+
automerge = { version = "0.1" }
base64 = { version= "0.13" }
byteorder = { version = "1.4" }
crossbeam-channel = { version = "0.5.6" }
@@ -30,6 +31,7 @@ sqlite = { version = "0.28.1", optional = true }
nonempty = { version = "0.8.0", features = ["serialize"] }
tempfile = { version = "3.3.0" }
thiserror = { version = "1" }
+
uuid = { version = "1.1.2", features = ["v4", "fast-rng", "serde"] }
zeroize = { version = "1.5.7" }

[dependencies.git2]
modified radicle/src/cob.rs
@@ -1,3 +1,12 @@
+
pub mod doc;
+
pub mod issue;
+
pub mod label;
+
pub mod patch;
+
pub mod shared;
+
pub mod store;
+
pub mod transaction;
+
pub mod value;
+

pub use cob::{
    identity, object::collaboration::error, CollaborativeObject, Create, Entry, History, ObjectId,
    TypeName, Update,
added radicle/src/cob/doc.rs
@@ -0,0 +1,243 @@
+
use std::collections::{HashMap, HashSet};
+
use std::fmt;
+
use std::hash::Hash;
+
use std::ops::Deref;
+
use std::str::FromStr;
+

+
use automerge::{Automerge, AutomergeError, ObjType};
+

+
use crate::cob::value::{FromValue, ValueError};
+

+
/// Error decoding a document.
+
#[derive(thiserror::Error, Debug)]
+
pub enum DocumentError {
+
    #[error(transparent)]
+
    Automerge(#[from] AutomergeError),
+
    #[error("property '{0}' not found in object")]
+
    PropertyNotFound(automerge::Prop),
+
    #[error("error decoding property `{0}`")]
+
    Property(String),
+
    #[error("property '{0}' is not an object")]
+
    NotAnObject(automerge::Prop),
+
    #[error("Unexpected object type: expected `{expected}`, got `{actual}`")]
+
    ObjectType {
+
        expected: automerge::ObjType,
+
        actual: automerge::ObjType,
+
    },
+
    #[error("error decoding value: {0}")]
+
    Value(#[from] ValueError),
+
    #[error("list under `{0}` cannot be empty")]
+
    EmptyList(&'static str),
+
}
+

+
/// Automerge document decoder.
+
///
+
/// Wraps a document, providing convenience functions. Derefs to the underlying doc.
+
#[derive(Copy, Clone)]
+
pub struct Document<'a> {
+
    doc: &'a Automerge,
+
}
+

+
impl<'a> Document<'a> {
+
    /// Create a new document from an automerge document.
+
    pub fn new(doc: &'a Automerge) -> Self {
+
        Self { doc }
+
    }
+

+
    /// Get the value of a property of an object.
+
    pub fn get<O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>, T: FromValue<'a>>(
+
        &self,
+
        id: O,
+
        prop: P,
+
    ) -> Result<T, DocumentError> {
+
        let prop = prop.into();
+
        let (val, _) = Document::get_raw(self, id, prop)?;
+

+
        T::from_value(val).map_err(DocumentError::from)
+
    }
+

+
    /// Get an object's raw property.
+
    pub fn get_raw<O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
+
        &self,
+
        id: O,
+
        prop: P,
+
    ) -> Result<(automerge::Value<'a>, automerge::ObjId), DocumentError> {
+
        let prop = prop.into();
+

+
        self.doc
+
            .get(id.as_ref(), prop.clone())?
+
            .ok_or(DocumentError::PropertyNotFound(prop))
+
    }
+

+
    /// Get the id of an object's property.
+
    pub fn get_id<O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
+
        &self,
+
        id: O,
+
        prop: P,
+
    ) -> Result<automerge::ObjId, DocumentError> {
+
        self.get_raw(id, prop).map(|(_, id)| id)
+
    }
+

+
    /// Get a property using a lookup function.
+
    pub fn lookup<V, O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
+
        &self,
+
        id: O,
+
        prop: P,
+
        lookup: fn(Document, &automerge::ObjId) -> Result<V, DocumentError>,
+
    ) -> Result<V, DocumentError> {
+
        let obj_id = self.get_id(&id, prop)?;
+
        lookup(*self, &obj_id)
+
    }
+

+
    /// Get a list-like value from an object.
+
    pub fn list<V, O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
+
        &self,
+
        id: O,
+
        prop: P,
+
        item: fn(Document, &automerge::ObjId) -> Result<V, DocumentError>,
+
    ) -> Result<Vec<V>, DocumentError> {
+
        let prop = prop.into();
+
        let id = id.as_ref();
+

+
        let Some((list, list_id)) = self.doc.get(id, prop.clone())? else {
+
            return Err(DocumentError::PropertyNotFound(prop));
+
        };
+
        let Some(objtype) = list.to_objtype() else {
+
            return Err(DocumentError::NotAnObject(prop));
+
        };
+
        if objtype != ObjType::List {
+
            return Err(DocumentError::ObjectType {
+
                expected: ObjType::List,
+
                actual: objtype,
+
            });
+
        }
+

+
        let mut objs: Vec<V> = Vec::new();
+
        for i in 0..self.length(&list_id) {
+
            let Some((_, item_id)) = self.doc.get(&list_id, i as usize)? else {
+
                return Err(DocumentError::PropertyNotFound(prop));
+
            };
+
            let item = item(*self, &item_id)?;
+

+
            objs.push(item);
+
        }
+
        Ok(objs)
+
    }
+

+
    /// Get a map-like value from an object.
+
    pub fn map<
+
        V: Default,
+
        K: Hash + Eq + FromStr,
+
        O: AsRef<automerge::ObjId>,
+
        P: Into<automerge::Prop>,
+
    >(
+
        &self,
+
        id: O,
+
        prop: P,
+
        mut value: impl FnMut(&mut V),
+
    ) -> Result<HashMap<K, V>, DocumentError> {
+
        let prop = prop.into();
+
        let id = id.as_ref();
+

+
        let Some((obj, obj_id)) = self.doc.get(id, prop.clone())? else {
+
            return Err(DocumentError::PropertyNotFound(prop))?;
+
        };
+
        let Some(objtype) = obj.to_objtype() else {
+
            return Err(DocumentError::NotAnObject(prop));
+
        };
+
        if objtype != ObjType::Map {
+
            return Err(DocumentError::ObjectType {
+
                expected: ObjType::Map,
+
                actual: objtype,
+
            });
+
        }
+

+
        let mut map = HashMap::new();
+
        for key in self.doc.keys(&obj_id) {
+
            let key = K::from_str(&key).map_err(|_| DocumentError::Property(key))?;
+
            let val = map.entry(key).or_default();
+

+
            value(val);
+
        }
+
        Ok(map)
+
    }
+

+
    /// Get a folded value out of an object.
+
    pub fn fold<
+
        T: Default,
+
        V: FromValue<'a> + fmt::Debug,
+
        O: AsRef<automerge::ObjId>,
+
        P: Into<automerge::Prop>,
+
    >(
+
        &self,
+
        id: O,
+
        prop: P,
+
        mut f: impl FnMut(&mut T, V),
+
    ) -> Result<T, DocumentError> {
+
        let prop = prop.into();
+
        let id = id.as_ref();
+

+
        let Some((obj, obj_id)) = self.doc.get(id, prop.clone())? else {
+
            return Err(DocumentError::PropertyNotFound(prop));
+
        };
+
        let Some(objtype) = obj.to_objtype() else {
+
            return Err(DocumentError::NotAnObject(prop));
+
        };
+
        if objtype != ObjType::List {
+
            return Err(DocumentError::ObjectType {
+
                expected: ObjType::List,
+
                actual: objtype,
+
            });
+
        }
+

+
        let mut acc = T::default();
+
        for i in 0..self.doc.length(&obj_id) {
+
            let Some((item, _)) = self.doc.get(&obj_id, i as usize)? else {
+
                return Err(DocumentError::PropertyNotFound(prop));
+
            };
+
            let val = V::from_value(item)?;
+

+
            f(&mut acc, val);
+
        }
+
        Ok(acc)
+
    }
+

+
    /// Get the keys of a map-like property.
+
    pub fn keys<K: Hash + Eq + FromStr, O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
+
        &self,
+
        id: O,
+
        prop: P,
+
    ) -> Result<HashSet<K>, DocumentError> {
+
        let prop = prop.into();
+
        let id = id.as_ref();
+

+
        let Some((obj, obj_id)) = self.doc.get(id, prop.clone())? else {
+
            return Err(DocumentError::PropertyNotFound(prop));
+
        };
+
        let Some(objtype) = obj.to_objtype() else {
+
            return Err(DocumentError::NotAnObject(prop));
+
        };
+
        if objtype != ObjType::Map {
+
            return Err(DocumentError::ObjectType {
+
                expected: ObjType::Map,
+
                actual: objtype,
+
            });
+
        }
+

+
        let mut keys = HashSet::new();
+
        for key in self.doc.keys(&obj_id) {
+
            let key = K::from_str(&key).map_err(|_| DocumentError::Property(key))?;
+

+
            keys.insert(key);
+
        }
+
        Ok(keys)
+
    }
+
}
+

+
impl<'a> Deref for Document<'a> {
+
    type Target = Automerge;
+

+
    fn deref(&self) -> &Self::Target {
+
        self.doc
+
    }
+
}
added radicle/src/cob/issue.rs
@@ -0,0 +1,766 @@
+
#![allow(clippy::large_enum_variant)]
+
use std::collections::{HashMap, HashSet};
+
use std::convert::TryFrom;
+
use std::ops::ControlFlow;
+
use std::str::FromStr;
+

+
use automerge::{Automerge, ObjType, ScalarValue, Value};
+
use once_cell::sync::Lazy;
+
use serde::{Deserialize, Serialize};
+

+
use crate::cob::doc::{Document, DocumentError};
+
use crate::cob::shared;
+
use crate::cob::shared::*;
+
use crate::cob::store::{Error, FromHistory, Store};
+
use crate::cob::transaction::{Transaction, TransactionError};
+
use crate::cob::value::{FromValue, ValueError};
+
use crate::cob::{Contents, History, ObjectId, TypeName};
+
use crate::prelude::*;
+

+
/// Type name of an issue.
+
pub static TYPENAME: Lazy<TypeName> =
+
    Lazy::new(|| FromStr::from_str("xyz.radicle.issue").expect("type name is valid"));
+

+
/// Identifier for an issue.
+
pub type IssueId = ObjectId;
+

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

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

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

+
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())),
+
        }
+
    }
+
}
+

+
/// An issue or "ticket".
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
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,
+
}
+

+
impl Issue {
+
    pub fn author(&self) -> &Author {
+
        &self.author
+
    }
+

+
    pub fn title(&self) -> &str {
+
        &self.title
+
    }
+

+
    pub fn state(&self) -> State {
+
        self.state
+
    }
+

+
    pub fn description(&self) -> &str {
+
        &self.comment.body
+
    }
+

+
    pub fn reactions(&self) -> &HashMap<Reaction, usize> {
+
        &self.comment.reactions
+
    }
+

+
    pub fn comments(&self) -> &[Comment<Replies>] {
+
        &self.discussion
+
    }
+

+
    pub fn labels(&self) -> &HashSet<Label> {
+
        &self.labels
+
    }
+

+
    pub fn timestamp(&self) -> Timestamp {
+
        self.timestamp
+
    }
+
}
+

+
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| {
+
            match entry.contents() {
+
                Contents::Automerge(bytes) => {
+
                    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) -> 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(Contents::Automerge(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(Contents::Automerge(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(Contents::Automerge(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(Contents::Automerge(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(Contents::Automerge(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(Contents::Automerge(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");
+
    }
+
}
added radicle/src/cob/label.rs
@@ -0,0 +1,176 @@
+
#![allow(clippy::large_enum_variant)]
+
use std::convert::TryFrom;
+
use std::ops::ControlFlow;
+
use std::str::FromStr;
+

+
use automerge::{Automerge, ObjType};
+
use once_cell::sync::Lazy;
+
use serde::{Deserialize, Serialize};
+

+
use crate::cob::doc::Document;
+
use crate::cob::shared::*;
+
use crate::cob::store::{Error, FromHistory, Store};
+
use crate::cob::transaction::TransactionError;
+
use crate::cob::{Contents, History, ObjectId, TypeName};
+
use crate::prelude::*;
+

+
pub static TYPENAME: Lazy<TypeName> =
+
    Lazy::new(|| FromStr::from_str("xyz.radicle.label").expect("type name is valid"));
+

+
/// Identifier for a label.
+
pub type LabelId = ObjectId;
+

+
/// Describes a label.
+
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
+
pub struct Label {
+
    pub name: String,
+
    pub description: String,
+
    pub color: Color,
+
}
+

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

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

+
impl TryFrom<&History> for Label {
+
    type Error = Error;
+

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

+
        Ok(label)
+
    }
+
}
+

+
impl TryFrom<Automerge> for Label {
+
    type Error = Error;
+

+
    fn try_from(doc: Automerge) -> Result<Self, Self::Error> {
+
        let doc = Document::new(&doc);
+
        let obj_id = doc.get_id(automerge::ObjId::Root, "label")?;
+
        let name = doc.get(&obj_id, "name")?;
+
        let description = doc.get(&obj_id, "description")?;
+
        let color = doc.get(&obj_id, "color")?;
+

+
        Ok(Self {
+
            name,
+
            description,
+
            color,
+
        })
+
    }
+
}
+

+
pub struct LabelStore<'a> {
+
    store: Store<'a, Label>,
+
}
+

+
impl<'a> LabelStore<'a> {
+
    pub fn new(store: Store<'a, Label>) -> Self {
+
        Self { store }
+
    }
+

+
    pub fn create<G: Signer>(
+
        &self,
+
        name: &str,
+
        description: &str,
+
        color: &Color,
+
        signer: &G,
+
    ) -> Result<LabelId, Error> {
+
        let author = self.store.author();
+
        let _timestamp = Timestamp::now();
+
        let contents = events::create(&author, name, description, color)?;
+
        let cob = self.store.create("Create label", contents, signer)?;
+

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

+
    pub fn get(&self, id: &LabelId) -> Result<Option<Label>, Error> {
+
        self.store.get(id)
+
    }
+
}
+

+
mod events {
+
    use super::*;
+
    use automerge::{
+
        transaction::{CommitOptions, Transactable},
+
        ObjId,
+
    };
+

+
    pub fn create(
+
        _author: &Author,
+
        name: &str,
+
        description: &str,
+
        color: &Color,
+
    ) -> Result<Contents, TransactionError> {
+
        let name = name.trim();
+
        if name.is_empty() {
+
            return Err(TransactionError::InvalidValue("name"));
+
        }
+
        let mut doc = Automerge::new();
+

+
        doc.transact_with::<_, _, TransactionError, _, ()>(
+
            |_| CommitOptions::default().with_message("Create label".to_owned()),
+
            |tx| {
+
                let label = tx.put_object(ObjId::Root, "label", ObjType::Map)?;
+

+
                tx.put(&label, "name", name)?;
+
                tx.put(&label, "description", description)?;
+
                tx.put(&label, "color", color.to_string())?;
+

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

+
        Ok(Contents::Automerge(doc.save_incremental()))
+
    }
+
}
+

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

+
    use crate::test;
+

+
    #[test]
+
    fn test_label_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 labels = store.labels();
+
        let label_id = labels
+
            .create(
+
                "bug",
+
                "Something that doesn't work",
+
                &Color::from_str("#ff0000").unwrap(),
+
                &signer,
+
            )
+
            .unwrap();
+
        let label = labels.get(&label_id).unwrap().unwrap();
+

+
        assert_eq!(label.name, "bug");
+
        assert_eq!(label.description, "Something that doesn't work");
+
        assert_eq!(label.color.to_string(), "#ff0000");
+
    }
+
}
added radicle/src/cob/patch.rs
@@ -0,0 +1,1082 @@
+
#![allow(clippy::too_many_arguments)]
+
use std::collections::{HashMap, HashSet};
+
use std::convert::TryFrom;
+
use std::fmt;
+
use std::ops::{ControlFlow, Deref, RangeInclusive};
+
use std::str::FromStr;
+
use std::sync::Arc;
+

+
use automerge::transaction::Transactable;
+
use automerge::{Automerge, AutomergeError, ObjType, ScalarValue, Value};
+
use nonempty::NonEmpty;
+
use once_cell::sync::Lazy;
+
use serde::{Deserialize, Serialize};
+

+
use crate::cob::doc::{Document, DocumentError};
+
use crate::cob::shared;
+
use crate::cob::shared::*;
+
use crate::cob::store::Error;
+
use crate::cob::store::{FromHistory, Store};
+
use crate::cob::transaction::{Transaction, TransactionError};
+
use crate::cob::value::{FromValue, ValueError};
+
use crate::cob::{Contents, History, ObjectId, TypeName};
+
use crate::git;
+
use crate::prelude::*;
+

+
/// Type name of a patch.
+
pub static TYPENAME: Lazy<TypeName> =
+
    Lazy::new(|| FromStr::from_str("xyz.radicle.patch").expect("type name is valid"));
+

+
/// Identifier for a patch.
+
pub type PatchId = ObjectId;
+

+
/// Unique identifier for a patch revision.
+
pub type RevisionId = uuid::Uuid;
+

+
/// Index of a revision in the revisions list.
+
pub type RevisionIx = usize;
+

+
/// Where a patch is intended to be merged.
+
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+
#[serde(rename_all = "lowercase")]
+
pub enum MergeTarget {
+
    /// Intended for the default branch of the project delegates.
+
    /// Note that if the delegations change while the patch is open,
+
    /// this will always mean whatever the "current" delegation set is.
+
    #[default]
+
    Delegates,
+
}
+

+
impl From<MergeTarget> for ScalarValue {
+
    fn from(target: MergeTarget) -> Self {
+
        match target {
+
            MergeTarget::Delegates => ScalarValue::from("delegates"),
+
        }
+
    }
+
}
+

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

+
        match state {
+
            "delegates" => Ok(Self::Delegates),
+
            _ => Err(ValueError::InvalidValue(value.to_string())),
+
        }
+
    }
+
}
+

+
/// A patch to a repository.
+
#[derive(Debug, Clone, Serialize)]
+
pub struct Patch<T = ()>
+
where
+
    T: Clone,
+
{
+
    /// Author of the patch.
+
    pub author: Author,
+
    /// Title of the patch.
+
    pub title: String,
+
    /// Current state of the patch.
+
    pub state: State,
+
    /// Target this patch is meant to be merged in.
+
    pub target: MergeTarget,
+
    /// Labels associated with the patch.
+
    pub labels: HashSet<Label>,
+
    /// List of patch revisions. The initial changeset is part of the
+
    /// first revision.
+
    pub revisions: NonEmpty<Revision<T>>,
+
    /// Patch creation time.
+
    pub timestamp: Timestamp,
+
}
+

+
impl Patch {
+
    pub fn head(&self) -> &git::Oid {
+
        &self.revisions.last().oid
+
    }
+

+
    pub fn version(&self) -> RevisionIx {
+
        self.revisions.len() - 1
+
    }
+

+
    pub fn latest(&self) -> (RevisionIx, &Revision) {
+
        let version = self.version();
+
        let revision = &self.revisions[version];
+

+
        (version, revision)
+
    }
+

+
    pub fn is_proposed(&self) -> bool {
+
        matches!(self.state, State::Proposed)
+
    }
+

+
    pub fn is_archived(&self) -> bool {
+
        matches!(self.state, State::Archived)
+
    }
+

+
    pub fn description(&self) -> &str {
+
        self.latest().1.description()
+
    }
+
}
+

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

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

+
impl TryFrom<Document<'_>> for Patch {
+
    type Error = DocumentError;
+

+
    fn try_from(doc: Document) -> Result<Self, Self::Error> {
+
        let obj_id = doc.get_id(automerge::ObjId::Root, "patch")?;
+
        let title = doc.get(&obj_id, "title")?;
+
        let author = doc.get(&obj_id, "author")?;
+
        let state = doc.get(&obj_id, "state")?;
+
        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 revisions =
+
            NonEmpty::from_vec(revisions).ok_or(DocumentError::EmptyList("revisions"))?;
+
        let author: Author = Author::new(author);
+

+
        Ok(Self {
+
            author,
+
            title,
+
            state,
+
            target,
+
            labels,
+
            revisions,
+
            timestamp,
+
        })
+
    }
+
}
+

+
impl TryFrom<&History> for Patch {
+
    type Error = Error;
+

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

+
        Ok(patch)
+
    }
+
}
+

+
pub struct PatchStore<'a> {
+
    store: Store<'a, Patch>,
+
}
+

+
impl<'a> Deref for PatchStore<'a> {
+
    type Target = Store<'a, Patch>;
+

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

+
impl<'a> PatchStore<'a> {
+
    /// Create a new patch store.
+
    pub fn new(store: Store<'a, Patch>) -> Self {
+
        Self { store }
+
    }
+

+
    /// Get a patch by id.
+
    pub fn get(&self, id: &ObjectId) -> Result<Option<Patch>, Error> {
+
        self.store.get(id)
+
    }
+

+
    /// Create a patch.
+
    pub fn create<G: Signer>(
+
        &self,
+
        title: &str,
+
        description: &str,
+
        target: MergeTarget,
+
        base: impl Into<git::Oid>,
+
        oid: impl Into<git::Oid>,
+
        labels: &[Label],
+
        signer: &G,
+
    ) -> Result<PatchId, Error> {
+
        let author = self.author();
+
        let timestamp = Timestamp::now();
+
        let revision = Revision::new(
+
            author.clone(),
+
            base.into(),
+
            oid.into(),
+
            description.to_owned(),
+
            timestamp,
+
        );
+
        let contents = events::create(&author, title, &revision, target, timestamp, labels)?;
+
        let cob = self.store.create("Create patch", contents, signer)?;
+

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

+
    /// Comment on a patch.
+
    pub fn comment<G: Signer>(
+
        &self,
+
        patch_id: &PatchId,
+
        revision_ix: RevisionIx,
+
        body: &str,
+
        signer: &G,
+
    ) -> Result<(), Error> {
+
        let author = self.author();
+
        let mut patch = self.store.get_raw(patch_id)?;
+
        let timestamp = Timestamp::now();
+
        let changes = events::comment(&mut patch, revision_ix, &author, body, timestamp)?;
+

+
        self.store
+
            .update(*patch_id, "Add comment", changes, signer)?;
+

+
        Ok(())
+
    }
+

+
    /// Update a patch with new code. Creates a new revision.
+
    pub fn update<G: Signer>(
+
        &self,
+
        patch_id: &PatchId,
+
        comment: impl ToString,
+
        base: impl Into<git::Oid>,
+
        oid: impl Into<git::Oid>,
+
        signer: &G,
+
    ) -> Result<RevisionIx, Error> {
+
        let author = self.author();
+
        let timestamp = Timestamp::now();
+
        let revision = Revision::new(
+
            author,
+
            base.into(),
+
            oid.into(),
+
            comment.to_string(),
+
            timestamp,
+
        );
+

+
        let mut patch = self.get_raw(patch_id)?;
+
        let (revision_ix, changes) = events::update(&mut patch, revision)?;
+

+
        self.store
+
            .update(*patch_id, "Update patch", changes, signer)?;
+

+
        Ok(revision_ix)
+
    }
+

+
    /// Reply to a patch comment.
+
    pub fn reply<G: Signer>(
+
        &self,
+
        patch_id: &PatchId,
+
        revision_ix: RevisionIx,
+
        comment_id: CommentId,
+
        reply: &str,
+
        signer: &G,
+
    ) -> Result<(), Error> {
+
        let author = self.author();
+
        let mut patch = self.get_raw(patch_id)?;
+
        let changes = events::reply(
+
            &mut patch,
+
            revision_ix,
+
            comment_id,
+
            &author,
+
            reply,
+
            Timestamp::now(),
+
        )?;
+

+
        self.store.update(*patch_id, "Reply", changes, signer)?;
+

+
        Ok(())
+
    }
+

+
    /// Review a patch revision.
+
    pub fn review<G: Signer>(
+
        &self,
+
        patch_id: &PatchId,
+
        revision_ix: RevisionIx,
+
        verdict: Option<Verdict>,
+
        comment: impl Into<String>,
+
        inline: Vec<CodeComment>,
+
        signer: &G,
+
    ) -> Result<(), Error> {
+
        let timestamp = Timestamp::now();
+
        let review = Review::new(self.author(), verdict, comment, inline, timestamp);
+

+
        let mut patch = self.get_raw(patch_id)?;
+
        let (_, changes) = events::review(&mut patch, revision_ix, review)?;
+

+
        self.store
+
            .update(*patch_id, "Review patch", changes, signer)?;
+

+
        Ok(())
+
    }
+

+
    /// Merge a patch revision.
+
    pub fn merge<G: Signer>(
+
        &self,
+
        patch_id: &PatchId,
+
        revision_ix: RevisionIx,
+
        commit: git::Oid,
+
        signer: &G,
+
    ) -> Result<Merge, Error> {
+
        let timestamp = Timestamp::now();
+
        let merge = Merge {
+
            node: *signer.public_key(),
+
            commit,
+
            timestamp,
+
        };
+

+
        let mut patch = self.get_raw(patch_id)?;
+
        let changes = events::merge(&mut patch, revision_ix, &merge)?;
+

+
        self.store
+
            .update(*patch_id, "Merge revision", changes, signer)?;
+

+
        Ok(merge)
+
    }
+

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

+
        Ok(cobs.len())
+
    }
+

+
    /// Get all patches for this project.
+
    pub fn all(&self) -> Result<Vec<(PatchId, Patch)>, Error> {
+
        let mut patches = self.store.list()?;
+
        patches.sort_by_key(|(_, p)| p.timestamp);
+

+
        Ok(patches)
+
    }
+

+
    /// Get proposed patches.
+
    pub fn proposed(&self) -> Result<impl Iterator<Item = (PatchId, Patch)>, Error> {
+
        let all = self.all()?;
+

+
        Ok(all.into_iter().filter(|(_, p)| p.is_proposed()))
+
    }
+

+
    /// Get patches proposed by the given key.
+
    pub fn proposed_by<'b>(
+
        &'b self,
+
        who: &'b PublicKey,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)> + '_, Error> {
+
        Ok(self.proposed()?.filter(move |(_, p)| p.author.id() == who))
+
    }
+
}
+

+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+
#[serde(rename_all = "lowercase")]
+
pub enum State {
+
    Draft,
+
    Proposed,
+
    Archived,
+
}
+

+
impl From<State> for ScalarValue {
+
    fn from(state: State) -> Self {
+
        match state {
+
            State::Proposed => ScalarValue::from("proposed"),
+
            State::Draft => ScalarValue::from("draft"),
+
            State::Archived => ScalarValue::from("archived"),
+
        }
+
    }
+
}
+

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

+
        match state {
+
            "proposed" => Ok(Self::Proposed),
+
            "draft" => Ok(Self::Draft),
+
            "archived" => Ok(Self::Archived),
+
            _ => Err(ValueError::InvalidValue(value.to_string())),
+
        }
+
    }
+
}
+

+
/// A patch revision.
+
#[derive(Debug, Clone, Serialize)]
+
pub struct Revision<T = ()> {
+
    /// Unique revision ID. This is useful in case of conflicts, eg.
+
    /// a user published a revision from two devices by mistake.
+
    pub id: RevisionId,
+
    /// Base branch commit (merge base).
+
    pub base: git::Oid,
+
    /// Reference to the Git object containing the code (revision head).
+
    pub oid: git::Oid,
+
    /// "Cover letter" for this changeset.
+
    pub comment: Comment,
+
    /// Discussion around this revision.
+
    pub discussion: Discussion,
+
    /// Reviews (one per user) of the changes.
+
    pub reviews: HashMap<NodeId, Review>,
+
    /// Merges of this revision into other repositories.
+
    pub merges: Vec<Merge>,
+
    /// Code changeset for this revision.
+
    pub changeset: T,
+
    /// When this revision was created.
+
    pub timestamp: Timestamp,
+
}
+

+
impl Revision {
+
    pub fn new(
+
        author: Author,
+
        base: git::Oid,
+
        oid: git::Oid,
+
        comment: String,
+
        timestamp: Timestamp,
+
    ) -> Self {
+
        Self {
+
            id: uuid::Uuid::new_v4(),
+
            base,
+
            oid,
+
            comment: Comment::new(author, comment, timestamp),
+
            discussion: Discussion::default(),
+
            reviews: HashMap::default(),
+
            merges: Vec::default(),
+
            changeset: (),
+
            timestamp,
+
        }
+
    }
+

+
    pub fn description(&self) -> &str {
+
        &self.comment.body
+
    }
+

+
    pub fn author(&self) -> &Author {
+
        &self.comment.author
+
    }
+

+
    /// Put this object into an automerge document.
+
    fn put<'a>(
+
        &self,
+
        mut tx: impl AsMut<automerge::transaction::Transaction<'a>>,
+
        id: &automerge::ObjId,
+
    ) -> Result<(), AutomergeError> {
+
        assert!(
+
            self.merges.is_empty(),
+
            "Cannot put revision with non-empty merges"
+
        );
+
        assert!(
+
            self.reviews.is_empty(),
+
            "Cannot put revision with non-empty reviews"
+
        );
+
        assert!(
+
            self.discussion.is_empty(),
+
            "Cannot put revision with non-empty discussion"
+
        );
+
        let tx = tx.as_mut();
+

+
        tx.put(id, "id", self.id.to_string())?;
+
        tx.put(id, "oid", self.oid.to_string())?;
+
        tx.put(id, "base", self.base.to_string())?;
+

+
        self.comment.put(tx, id)?;
+

+
        tx.put_object(id, "discussion", ObjType::List)?;
+
        tx.put_object(id, "reviews", ObjType::Map)?;
+
        tx.put_object(id, "merges", ObjType::List)?;
+
        tx.put(id, "timestamp", self.timestamp)?;
+

+
        Ok(())
+
    }
+
}
+

+
/// A merged patch revision.
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Merge {
+
    /// Owner of repository that this patch was merged into.
+
    pub node: NodeId,
+
    /// Base branch commit that contains the revision.
+
    pub commit: git::Oid,
+
    /// When this merged was performed.
+
    pub timestamp: Timestamp,
+
}
+

+
/// A patch review verdict.
+
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(rename_all = "lowercase")]
+
pub enum Verdict {
+
    /// Accept patch.
+
    Accept,
+
    /// Reject patch.
+
    Reject,
+
}
+

+
impl fmt::Display for Verdict {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            Self::Accept => write!(f, "accept"),
+
            Self::Reject => write!(f, "reject"),
+
        }
+
    }
+
}
+

+
impl From<Verdict> for ScalarValue {
+
    fn from(verdict: Verdict) -> Self {
+
        #[allow(clippy::unwrap_used)]
+
        let s = serde_json::to_string(&verdict).unwrap(); // Cannot fail.
+
        ScalarValue::from(s)
+
    }
+
}
+

+
impl<'a> FromValue<'a> for Verdict {
+
    fn from_value(value: Value) -> Result<Self, ValueError> {
+
        let verdict = value.to_str().ok_or(ValueError::InvalidType)?;
+
        serde_json::from_str(verdict).map_err(|e| ValueError::Other(Arc::new(e)))
+
    }
+
}
+

+
/// Code location, used for attaching comments.
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct CodeLocation {
+
    /// Line number commented on.
+
    pub lines: RangeInclusive<usize>,
+
    /// Commit commented on.
+
    pub commit: git::Oid,
+
    /// File being commented on.
+
    pub blob: git::Oid,
+
}
+

+
/// Comment on code.
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct CodeComment {
+
    /// Code location of the comment.
+
    location: CodeLocation,
+
    /// Comment.
+
    comment: Comment,
+
}
+

+
/// A patch review on a revision.
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Review {
+
    /// Review author.
+
    pub author: Author,
+
    /// Review verdict.
+
    pub verdict: Option<Verdict>,
+
    /// Review general comment.
+
    pub comment: Comment<Replies>,
+
    /// Review inline code comments.
+
    pub inline: Vec<CodeComment>,
+
    /// Review timestamp.
+
    pub timestamp: Timestamp,
+
}
+

+
impl Review {
+
    pub fn new(
+
        author: Author,
+
        verdict: Option<Verdict>,
+
        comment: impl Into<String>,
+
        inline: Vec<CodeComment>,
+
        timestamp: Timestamp,
+
    ) -> Self {
+
        let comment = Comment::new(author.clone(), comment.into(), timestamp);
+

+
        Self {
+
            author,
+
            verdict,
+
            comment,
+
            inline,
+
            timestamp,
+
        }
+
    }
+

+
    /// Put this object into an automerge document.
+
    fn put<'a>(
+
        &self,
+
        mut tx: impl AsMut<automerge::transaction::Transaction<'a>>,
+
        id: &automerge::ObjId,
+
    ) -> Result<(), AutomergeError> {
+
        assert!(
+
            self.inline.is_empty(),
+
            "Cannot put review with non-empty inline comments"
+
        );
+
        let tx = tx.as_mut();
+

+
        tx.put(id, "author", &self.author)?;
+
        tx.put(
+
            id,
+
            "verdict",
+
            if let Some(v) = self.verdict {
+
                v.into()
+
            } else {
+
                ScalarValue::Null
+
            },
+
        )?;
+

+
        self.comment.put(tx, id)?;
+

+
        tx.put_object(id, "inline", ObjType::List)?;
+
        tx.put(id, "timestamp", self.timestamp)?;
+

+
        Ok(())
+
    }
+
}
+

+
mod lookup {
+
    use super::*;
+

+
    pub fn revision(
+
        doc: Document,
+
        revision_id: &automerge::ObjId,
+
    ) -> Result<Revision, DocumentError> {
+
        let comment_id = doc.get_id(revision_id, "comment")?;
+
        let reviews_id = doc.get_id(revision_id, "reviews")?;
+
        let id = doc.get(revision_id, "id")?;
+
        let base = doc.get(revision_id, "base")?;
+
        let oid = doc.get(revision_id, "oid")?;
+
        let timestamp = doc.get(revision_id, "timestamp")?;
+
        let merges: Vec<Merge> = doc.list(revision_id, "merges", self::merge)?;
+

+
        // Discussion.
+
        let comment = shared::lookup::comment(doc, &comment_id)?;
+
        let discussion: Discussion = doc.list(revision_id, "discussion", shared::lookup::thread)?;
+

+
        // Reviews.
+
        let mut reviews: HashMap<NodeId, Review> = HashMap::new();
+
        for key in (*doc).keys(&reviews_id) {
+
            let review_id = doc.get_id(&reviews_id, key)?;
+
            let review = self::review(doc, &review_id)?;
+

+
            reviews.insert(*review.author.id(), review);
+
        }
+

+
        Ok(Revision {
+
            id,
+
            base,
+
            oid,
+
            comment,
+
            discussion,
+
            reviews,
+
            merges,
+
            changeset: (),
+
            timestamp,
+
        })
+
    }
+

+
    pub fn merge(doc: Document, obj_id: &automerge::ObjId) -> Result<Merge, DocumentError> {
+
        let node = doc.get(obj_id, "peer")?;
+
        let commit = doc.get(obj_id, "commit")?;
+
        let timestamp = doc.get(obj_id, "timestamp")?;
+

+
        Ok(Merge {
+
            node,
+
            commit,
+
            timestamp,
+
        })
+
    }
+

+
    pub fn review(doc: Document, obj_id: &automerge::ObjId) -> Result<Review, DocumentError> {
+
        let author = doc.get(obj_id, "author")?;
+
        let verdict = doc.get(obj_id, "verdict")?;
+
        let timestamp = doc.get(obj_id, "timestamp")?;
+
        let comment = doc.lookup(obj_id, "comment", shared::lookup::thread)?;
+
        let inline = vec![];
+

+
        Ok(Review {
+
            author: Author::new(author),
+
            comment,
+
            verdict,
+
            inline,
+
            timestamp,
+
        })
+
    }
+
}
+

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

+
    pub fn create(
+
        author: &Author,
+
        title: &str,
+
        revision: &Revision,
+
        target: MergeTarget,
+
        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 _patch = doc
+
            .transact_with::<_, _, TransactionError, _, ()>(
+
                |_| CommitOptions::default().with_message("Create patch".to_owned()),
+
                |tx| {
+
                    let mut tx = Transaction::new(tx);
+
                    let patch_id = tx.put_object(ObjId::Root, "patch", ObjType::Map)?;
+

+
                    tx.put(&patch_id, "title", title)?;
+
                    tx.put(&patch_id, "author", author)?;
+
                    tx.put(&patch_id, "state", State::Proposed)?;
+
                    tx.put(&patch_id, "target", target)?;
+
                    tx.put(&patch_id, "timestamp", timestamp)?;
+

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

+
                    let revisions_id = tx.put_object(&patch_id, "revisions", ObjType::List)?;
+
                    let revision_id = tx.insert_object(&revisions_id, 0, ObjType::Map)?;
+

+
                    revision.put(tx, &revision_id)?;
+

+
                    Ok(patch_id)
+
                },
+
            )
+
            .map_err(|failure| failure.error)?
+
            .result;
+

+
        Ok(Contents::Automerge(doc.save_incremental()))
+
    }
+

+
    pub fn comment(
+
        patch: &mut Automerge,
+
        revision_ix: RevisionIx,
+
        author: &Author,
+
        body: &str,
+
        timestamp: Timestamp,
+
    ) -> Result<Contents, TransactionError> {
+
        let _comment = patch
+
            .transact_with::<_, _, TransactionError, _, ()>(
+
                |_| CommitOptions::default().with_message("Add comment".to_owned()),
+
                |t| {
+
                    let mut tx = Transaction::new(t);
+
                    let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
+
                    let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
+
                    let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
+
                    let (_, discussion_id) = tx.get(&revision_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 = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
+

+
        Ok(Contents::Automerge(change))
+
    }
+

+
    pub fn update(
+
        patch: &mut Automerge,
+
        revision: Revision,
+
    ) -> Result<(RevisionIx, Contents), TransactionError> {
+
        let revision_ix = patch
+
            .transact_with::<_, _, TransactionError, _, ()>(
+
                |_| CommitOptions::default().with_message("Merge revision".to_owned()),
+
                |tx| {
+
                    let mut tx = Transaction::new(tx);
+
                    let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
+
                    let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
+

+
                    let ix = tx.length(&revisions_id);
+
                    let revision_id = tx.insert_object(&revisions_id, ix, ObjType::Map)?;
+

+
                    revision.put(tx, &revision_id)?;
+

+
                    Ok(ix)
+
                },
+
            )
+
            .map_err(|failure| failure.error)?
+
            .result;
+

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

+
        Ok((revision_ix, Contents::Automerge(change)))
+
    }
+

+
    pub fn reply(
+
        patch: &mut Automerge,
+
        revision_ix: RevisionIx,
+
        comment_id: CommentId,
+
        author: &Author,
+
        body: &str,
+
        timestamp: Timestamp,
+
    ) -> Result<Contents, TransactionError> {
+
        patch
+
            .transact_with::<_, _, TransactionError, _, ()>(
+
                |_| CommitOptions::default().with_message("Reply".to_owned()),
+
                |tx| {
+
                    let mut tx = Transaction::new(tx);
+
                    let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
+
                    let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
+
                    let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
+
                    let (_, discussion_id) = tx.get(&revision_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 = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
+

+
        Ok(Contents::Automerge(change))
+
    }
+

+
    pub fn review(
+
        patch: &mut Automerge,
+
        revision_ix: RevisionIx,
+
        review: Review,
+
    ) -> Result<((), Contents), TransactionError> {
+
        patch
+
            .transact_with::<_, _, TransactionError, _, ()>(
+
                |_| CommitOptions::default().with_message("Review patch".to_owned()),
+
                |tx| {
+
                    let mut tx = Transaction::new(tx);
+
                    let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
+
                    let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
+
                    let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
+
                    let (_, reviews_id) = tx.get(&revision_id, "reviews")?;
+

+
                    let review_id =
+
                        tx.put_object(&reviews_id, review.author.id.to_human(), ObjType::Map)?;
+

+
                    review.put(tx, &review_id)?;
+

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

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

+
        Ok(((), Contents::Automerge(change)))
+
    }
+

+
    pub fn merge(
+
        patch: &mut Automerge,
+
        revision_ix: RevisionIx,
+
        merge: &Merge,
+
    ) -> Result<Contents, TransactionError> {
+
        patch
+
            .transact_with::<_, _, TransactionError, _, ()>(
+
                |_| CommitOptions::default().with_message("Merge revision".to_owned()),
+
                |tx| {
+
                    let mut tx = Transaction::new(tx);
+
                    let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
+
                    let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
+
                    let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
+
                    let (_, merges_id) = tx.get(&revision_id, "merges")?;
+

+
                    let length = tx.length(&merges_id);
+
                    let merge_id = tx.insert_object(&merges_id, length, ObjType::Map)?;
+

+
                    tx.put(&merge_id, "peer", merge.node.to_string())?;
+
                    tx.put(&merge_id, "commit", merge.commit.to_string())?;
+
                    tx.put(&merge_id, "timestamp", merge.timestamp)?;
+

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

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

+
        Ok(Contents::Automerge(change))
+
    }
+
}
+

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

+
    #[test]
+
    fn test_patch_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 patches = store.patches();
+
        let author = *signer.public_key();
+
        let timestamp = Timestamp::now();
+
        let target = MergeTarget::Delegates;
+
        let oid = git::Oid::from(git2::Oid::zero());
+
        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
        let patch_id = patches
+
            .create(
+
                "My first patch",
+
                "Blah blah blah.",
+
                target,
+
                base,
+
                oid,
+
                &[],
+
                &signer,
+
            )
+
            .unwrap();
+
        let patch = patches.get(&patch_id).unwrap().unwrap();
+

+
        assert_eq!(&patch.title, "My first patch");
+
        assert_eq!(patch.author.id(), &author);
+
        assert_eq!(patch.state, State::Proposed);
+
        assert!(patch.timestamp >= timestamp);
+

+
        let revision = patch.revisions.head;
+

+
        assert_eq!(revision.author(), &store.author());
+
        assert_eq!(revision.comment.body, "Blah blah blah.");
+
        assert_eq!(revision.discussion.len(), 0);
+
        assert_eq!(revision.oid, oid);
+
        assert_eq!(revision.base, base);
+
        assert!(revision.reviews.is_empty());
+
        assert!(revision.merges.is_empty());
+
    }
+

+
    #[test]
+
    fn test_patch_merge() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        let store = Store::open(*signer.public_key(), &project).unwrap();
+
        let patches = store.patches();
+
        let target = MergeTarget::Delegates;
+
        let oid = git::Oid::from(git2::Oid::zero());
+
        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
        let patch_id = patches
+
            .create(
+
                "My first patch",
+
                "Blah blah blah.",
+
                target,
+
                base,
+
                oid,
+
                &[],
+
                &signer,
+
            )
+
            .unwrap();
+

+
        let _merge = patches.merge(&patch_id, 0, base, &signer).unwrap();
+
        let patch = patches.get(&patch_id).unwrap().unwrap();
+
        let merges = patch.revisions.head.merges;
+

+
        assert_eq!(merges.len(), 1);
+
        assert_eq!(merges[0].node, *signer.public_key());
+
        assert_eq!(merges[0].commit, base);
+
    }
+

+
    #[test]
+
    fn test_patch_review() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        let store = Store::open(*signer.public_key(), &project).unwrap();
+
        let patches = store.patches();
+
        let whoami = store.author();
+
        let target = MergeTarget::Delegates;
+
        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
        let rev_oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
+
        let patch_id = patches
+
            .create(
+
                "My first patch",
+
                "Blah blah blah.",
+
                target,
+
                base,
+
                rev_oid,
+
                &[],
+
                &signer,
+
            )
+
            .unwrap();
+

+
        patches
+
            .review(&patch_id, 0, Some(Verdict::Accept), "LGTM", vec![], &signer)
+
            .unwrap();
+
        let patch = patches.get(&patch_id).unwrap().unwrap();
+
        let reviews = patch.revisions.head.reviews;
+
        assert_eq!(reviews.len(), 1);
+

+
        let review = reviews.get(whoami.id()).unwrap();
+
        assert_eq!(review.author.id(), whoami.id());
+
        assert_eq!(review.verdict, Some(Verdict::Accept));
+
        assert_eq!(review.comment.body.as_str(), "LGTM");
+
    }
+

+
    #[test]
+
    fn test_patch_update() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        let store = Store::open(*signer.public_key(), &project).unwrap();
+
        let patches = store.patches();
+
        let target = MergeTarget::Delegates;
+
        let base = git::Oid::from_str("af08e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
        let rev0_oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
+
        let rev1_oid = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
        let patch_id = patches
+
            .create(
+
                "My first patch",
+
                "Blah blah blah.",
+
                target,
+
                base,
+
                rev0_oid,
+
                &[],
+
                &signer,
+
            )
+
            .unwrap();
+

+
        let patch = patches.get(&patch_id).unwrap().unwrap();
+
        assert_eq!(patch.description(), "Blah blah blah.");
+
        assert_eq!(patch.version(), 0);
+

+
        let revision_id = patches
+
            .update(&patch_id, "I've made changes.", base, rev1_oid, &signer)
+
            .unwrap();
+

+
        assert_eq!(revision_id, 1);
+

+
        let patch = patches.get(&patch_id).unwrap().unwrap();
+
        assert_eq!(patch.description(), "I've made changes.");
+

+
        assert_eq!(patch.revisions.len(), 2);
+
        assert_eq!(patch.version(), 1);
+

+
        let (id, revision) = patch.latest();
+

+
        assert_eq!(id, 1);
+
        assert_eq!(revision.oid, rev1_oid);
+
        assert_eq!(revision.description(), "I've made changes.");
+
    }
+
}
added radicle/src/cob/shared.rs
@@ -0,0 +1,384 @@
+
#![allow(clippy::large_enum_variant)]
+
use std::borrow::Borrow;
+
use std::collections::HashMap;
+
use std::fmt;
+
use std::hash::Hash;
+
use std::str::FromStr;
+
use std::time::{SystemTime, UNIX_EPOCH};
+

+
use automerge::transaction::Transactable;
+
use automerge::{AutomergeError, ObjType, ScalarValue};
+
use serde::{Deserialize, Serialize};
+

+
use crate::cob::doc::{Document, DocumentError};
+
use crate::cob::value::{FromValue, Value, ValueError};
+
use crate::prelude::*;
+

+
/// A discussion thread.
+
pub type Discussion = Vec<Comment<Replies>>;
+

+
#[derive(thiserror::Error, Debug)]
+
pub enum ReactionError {
+
    #[error("invalid reaction")]
+
    InvalidReaction,
+
}
+

+
#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)]
+
#[serde(transparent)]
+
pub struct Reaction {
+
    pub emoji: char,
+
}
+

+
impl Reaction {
+
    pub fn new(emoji: char) -> Result<Self, ReactionError> {
+
        if emoji.is_whitespace() || emoji.is_ascii() || emoji.is_alphanumeric() {
+
            return Err(ReactionError::InvalidReaction);
+
        }
+
        Ok(Self { emoji })
+
    }
+
}
+

+
impl FromStr for Reaction {
+
    type Err = ReactionError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        let mut chars = s.chars();
+
        let first = chars.next().ok_or(ReactionError::InvalidReaction)?;
+

+
        // Reactions should not consist of more than a single emoji.
+
        if chars.next().is_some() {
+
            return Err(ReactionError::InvalidReaction);
+
        }
+
        Reaction::new(first)
+
    }
+
}
+

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

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

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

+
        if name.chars().any(|c| c.is_whitespace()) || name.is_empty() {
+
            return Err(LabelError::InvalidName(name));
+
        }
+
        Ok(Self(name))
+
    }
+

+
    pub fn name(&self) -> &str {
+
        self.0.as_str()
+
    }
+
}
+

+
impl FromStr for Label {
+
    type Err = LabelError;
+

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

+
impl From<Label> for String {
+
    fn from(Label(name): Label) -> Self {
+
        name
+
    }
+
}
+

+
/// RGB color.
+
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
+
pub struct Color(u32);
+

+
#[derive(thiserror::Error, Debug)]
+
pub enum ColorConversionError {
+
    #[error("invalid format: expect '#rrggbb'")]
+
    InvalidFormat,
+
    #[error(transparent)]
+
    ParseInt(#[from] std::num::ParseIntError),
+
}
+

+
impl fmt::Display for Color {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        write!(f, "#{:06x}", self.0)
+
    }
+
}
+

+
impl FromStr for Color {
+
    type Err = ColorConversionError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        let hex = s.replace('#', "").to_lowercase();
+

+
        if hex.chars().count() != 6 {
+
            return Err(ColorConversionError::InvalidFormat);
+
        }
+

+
        match u32::from_str_radix(&hex, 16) {
+
            Ok(n) => Ok(Color(n)),
+
            Err(e) => Err(e.into()),
+
        }
+
    }
+
}
+

+
impl<'a> FromValue<'a> for Color {
+
    fn from_value(val: Value<'a>) -> Result<Self, ValueError> {
+
        let color = String::from_value(val)?;
+
        let color = Self::from_str(&color).map_err(|_| ValueError::InvalidValue(color))?;
+

+
        Ok(color)
+
    }
+
}
+

+
impl Serialize for Color {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: serde::ser::Serializer,
+
    {
+
        let s = self.to_string();
+
        serializer.serialize_str(&s)
+
    }
+
}
+

+
impl<'a> Deserialize<'a> for Color {
+
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+
    where
+
        D: serde::de::Deserializer<'a>,
+
    {
+
        let color = String::deserialize(deserializer)?;
+
        Self::from_str(&color).map_err(serde::de::Error::custom)
+
    }
+
}
+

+
/// Author.
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+
pub struct Author {
+
    pub id: NodeId,
+
}
+

+
impl Author {
+
    pub fn new(id: NodeId) -> Self {
+
        Self { id }
+
    }
+

+
    pub fn id(&self) -> &NodeId {
+
        &self.id
+
    }
+
}
+

+
impl From<&Author> for ScalarValue {
+
    fn from(author: &Author) -> Self {
+
        ScalarValue::from(author.id.to_human())
+
    }
+
}
+

+
/// Local id of a comment in an issue.
+
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone)]
+
pub struct CommentId {
+
    /// Represents the index of the comment in the thread,
+
    /// with `0` being the top-level comment.
+
    ix: usize,
+
}
+

+
impl CommentId {
+
    /// Root comment.
+
    pub const fn root() -> Self {
+
        Self { ix: 0 }
+
    }
+
}
+

+
impl From<usize> for CommentId {
+
    fn from(ix: usize) -> Self {
+
        Self { ix }
+
    }
+
}
+

+
impl From<CommentId> for usize {
+
    fn from(id: CommentId) -> Self {
+
        id.ix
+
    }
+
}
+

+
/// Comment replies.
+
pub type Replies = Vec<Comment>;
+

+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Comment<R = ()> {
+
    pub author: Author,
+
    pub body: String,
+
    pub reactions: HashMap<Reaction, usize>,
+
    pub replies: R,
+
    pub timestamp: Timestamp,
+
}
+

+
impl<R: Default> Comment<R> {
+
    pub fn new(author: Author, body: String, timestamp: Timestamp) -> Self {
+
        Self {
+
            author,
+
            body,
+
            reactions: HashMap::default(),
+
            replies: R::default(),
+
            timestamp,
+
        }
+
    }
+
}
+

+
impl Comment<()> {
+
    pub(super) fn put(
+
        &self,
+
        tx: &mut automerge::transaction::Transaction,
+
        id: &automerge::ObjId,
+
    ) -> Result<(), AutomergeError> {
+
        let comment_id = tx.put_object(id, "comment", ObjType::Map)?;
+

+
        assert!(
+
            self.reactions.is_empty(),
+
            "Cannot put comment with non-empty reactions"
+
        );
+

+
        tx.put(&comment_id, "body", self.body.trim())?;
+
        tx.put(&comment_id, "author", self.author.id().to_string())?;
+
        tx.put(&comment_id, "timestamp", self.timestamp)?;
+
        tx.put_object(&comment_id, "reactions", ObjType::Map)?;
+

+
        Ok(())
+
    }
+
}
+

+
impl Comment<Replies> {
+
    pub(super) fn put(
+
        &self,
+
        tx: &mut automerge::transaction::Transaction,
+
        id: &automerge::ObjId,
+
    ) -> Result<(), AutomergeError> {
+
        let comment_id = tx.put_object(id, "comment", ObjType::Map)?;
+

+
        assert!(
+
            self.reactions.is_empty(),
+
            "Cannot put comment with non-empty reactions"
+
        );
+
        assert!(
+
            self.replies.is_empty(),
+
            "Cannot put comment with non-empty replies"
+
        );
+

+
        tx.put(&comment_id, "body", self.body.trim())?;
+
        tx.put(&comment_id, "author", self.author.id().to_string())?;
+
        tx.put(&comment_id, "timestamp", self.timestamp)?;
+
        tx.put_object(&comment_id, "reactions", ObjType::Map)?;
+
        tx.put_object(&comment_id, "replies", ObjType::List)?;
+

+
        Ok(())
+
    }
+
}
+

+
#[derive(Debug, Copy, Clone, PartialOrd, PartialEq, Ord, Eq, Serialize, Deserialize)]
+
#[serde(transparent)]
+
pub struct Timestamp {
+
    seconds: u64,
+
}
+

+
impl Timestamp {
+
    pub fn new(seconds: u64) -> Self {
+
        Self { seconds }
+
    }
+

+
    pub fn now() -> Self {
+
        #[allow(clippy::unwrap_used)] // Safe because Unix was already invented!
+
        let duration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
+

+
        Self {
+
            seconds: duration.as_secs(),
+
        }
+
    }
+

+
    pub fn as_secs(&self) -> u64 {
+
        self.seconds
+
    }
+
}
+

+
impl From<Timestamp> for ScalarValue {
+
    fn from(ts: Timestamp) -> Self {
+
        ScalarValue::Timestamp(ts.seconds as i64)
+
    }
+
}
+

+
impl<'a> FromValue<'a> for Timestamp {
+
    fn from_value(val: Value<'a>) -> Result<Self, ValueError> {
+
        if let Value::Scalar(scalar) = &val {
+
            if let ScalarValue::Timestamp(ts) = scalar.borrow() {
+
                return Ok(Self {
+
                    seconds: *ts as u64,
+
                });
+
            }
+
        }
+
        Err(ValueError::InvalidValue(val.to_string()))
+
    }
+
}
+

+
pub mod lookup {
+
    use super::{Author, Comment, HashMap, Reaction, Replies};
+
    use super::{Document, DocumentError};
+

+
    pub fn comment(doc: Document, obj_id: &automerge::ObjId) -> Result<Comment<()>, DocumentError> {
+
        let author = doc.get(obj_id, "author").map(Author::new)?;
+
        let body = doc.get(obj_id, "body")?;
+
        let timestamp = doc.get(obj_id, "timestamp")?;
+
        let reactions: HashMap<Reaction, usize> = doc.map(obj_id, "reactions", |v| *v += 1)?;
+

+
        Ok(Comment {
+
            author,
+
            body,
+
            reactions,
+
            replies: (),
+
            timestamp,
+
        })
+
    }
+

+
    pub fn thread(
+
        doc: Document,
+
        obj_id: &automerge::ObjId,
+
    ) -> Result<Comment<Replies>, DocumentError> {
+
        let comment = self::comment(doc, obj_id)?;
+
        let replies = doc.list(obj_id, "replies", self::comment)?;
+

+
        Ok(Comment {
+
            author: comment.author,
+
            body: comment.body,
+
            reactions: comment.reactions,
+
            replies,
+
            timestamp: comment.timestamp,
+
        })
+
    }
+
}
+

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

+
    #[test]
+
    fn test_color() {
+
        let c = Color::from_str("#ffccaa").unwrap();
+
        assert_eq!(c.to_string(), "#ffccaa".to_owned());
+
        assert_eq!(serde_json::to_string(&c).unwrap(), "\"#ffccaa\"".to_owned());
+
        assert_eq!(serde_json::from_str::<'_, Color>("\"#ffccaa\"").unwrap(), c);
+

+
        let c = Color::from_str("#0000aa").unwrap();
+
        assert_eq!(c.to_string(), "#0000aa".to_owned());
+

+
        let c = Color::from_str("#aa0000").unwrap();
+
        assert_eq!(c.to_string(), "#aa0000".to_owned());
+

+
        let c = Color::from_str("#00aa00").unwrap();
+
        assert_eq!(c.to_string(), "#00aa00".to_owned());
+

+
        Color::from_str("#aa00").unwrap_err();
+
        Color::from_str("#abc").unwrap_err();
+
    }
+
}
added radicle/src/cob/store.rs
@@ -0,0 +1,204 @@
+
//! Generic COB storage.
+
#![allow(clippy::large_enum_variant)]
+
use std::marker::PhantomData;
+
use std::ops::ControlFlow;
+

+
use automerge::{Automerge, AutomergeError};
+

+
use crate::cob;
+
use crate::cob::doc::DocumentError;
+
use crate::cob::shared::Author;
+
use crate::cob::transaction::TransactionError;
+
use crate::cob::CollaborativeObject;
+
use crate::cob::{issue, label, patch};
+
use crate::cob::{Contents, Create, History, ObjectId, TypeName, Update};
+
use crate::crypto::PublicKey;
+
use crate::git;
+
use crate::identity::project;
+
use crate::prelude::*;
+
use crate::storage::git as storage;
+

+
/// Store error.
+
#[derive(Debug, thiserror::Error)]
+
pub enum Error {
+
    #[error("create error: {0}")]
+
    Create(#[from] cob::error::Create),
+
    #[error("update error: {0}")]
+
    Update(#[from] cob::error::Update),
+
    #[error("retrieve error: {0}")]
+
    Retrieve(#[from] cob::error::Retrieve),
+
    #[error(transparent)]
+
    Automerge(#[from] AutomergeError),
+
    #[error(transparent)]
+
    Transaction(#[from] TransactionError),
+
    #[error(transparent)]
+
    Identity(#[from] project::IdentityError),
+
    #[error(transparent)]
+
    Document(#[from] DocumentError),
+
    #[error("object `{1}`of type `{0}` was not found")]
+
    NotFound(TypeName, ObjectId),
+
}
+

+
/// A type that can be materialized from an event history.
+
/// All collaborative objects implement this trait.
+
pub trait FromHistory: Sized {
+
    /// The object type name.
+
    fn type_name() -> &'static TypeName;
+
    /// Create an object from a history.
+
    fn from_history(history: &History) -> Result<Self, Error>;
+
}
+

+
/// Storage for collaborative objects of a specific type `T` in a single project.
+
pub struct Store<'a, T> {
+
    whoami: PublicKey,
+
    project: project::Identity<git::Oid>,
+
    raw: &'a storage::Repository,
+
    witness: PhantomData<T>,
+
}
+

+
impl<'a, T> AsRef<storage::Repository> for Store<'a, T> {
+
    fn as_ref(&self) -> &storage::Repository {
+
        self.raw
+
    }
+
}
+

+
impl<'a> Store<'a, ()> {
+
    /// Open a new generic store.
+
    pub fn open(whoami: PublicKey, store: &'a storage::Repository) -> Result<Self, Error> {
+
        let project = project::Identity::load(&whoami, store)?;
+

+
        Ok(Self {
+
            project,
+
            whoami,
+
            raw: store,
+
            witness: PhantomData,
+
        })
+
    }
+

+
    /// Return a patch store from this generic store.
+
    pub fn patches(&self) -> patch::PatchStore<'_> {
+
        patch::PatchStore::new(Store {
+
            whoami: self.whoami,
+
            project: self.project.clone(),
+
            raw: self.raw,
+
            witness: PhantomData,
+
        })
+
    }
+

+
    /// 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 {
+
            whoami: self.whoami,
+
            project: self.project.clone(),
+
            raw: self.raw,
+
            witness: PhantomData,
+
        })
+
    }
+
}
+

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

+
impl<'a, T: FromHistory> Store<'a, T> {
+
    /// Update an object.
+
    pub fn update<G: Signer>(
+
        &self,
+
        object_id: ObjectId,
+
        message: &'static str,
+
        changes: Contents,
+
        signer: &G,
+
    ) -> Result<CollaborativeObject, cob::error::Update> {
+
        cob::update(
+
            self.raw,
+
            signer,
+
            &self.project,
+
            Update {
+
                author: Some(cob::Author::from(*signer.public_key())),
+
                object_id,
+
                typename: T::type_name().clone(),
+
                message: message.to_owned(),
+
                changes,
+
            },
+
        )
+
    }
+

+
    /// Create an object.
+
    pub fn create<G: Signer>(
+
        &self,
+
        message: &'static str,
+
        contents: Contents,
+
        signer: &G,
+
    ) -> Result<CollaborativeObject, cob::error::Create> {
+
        cob::create(
+
            self.raw,
+
            signer,
+
            &self.project,
+
            Create {
+
                author: Some(cob::Author::from(*signer.public_key())),
+
                typename: T::type_name().clone(),
+
                message: message.to_owned(),
+
                contents,
+
            },
+
        )
+
    }
+

+
    /// Get an object.
+
    pub fn get(&self, id: &ObjectId) -> Result<Option<T>, Error> {
+
        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)?;
+

+
            Ok(Some(obj))
+
        } else {
+
            Ok(None)
+
        }
+
    }
+

+
    /// Get an object as a raw automerge document.
+
    pub fn get_raw(&self, id: &ObjectId) -> Result<Automerge, Error> {
+
        let Some(cob) = cob::get(self.raw, T::type_name(), id)? else {
+
            return Err(Error::NotFound(T::type_name().clone(), *id));
+
        };
+

+
        let doc = cob.history().traverse(Vec::new(), |mut doc, entry| {
+
            match entry.contents() {
+
                Contents::Automerge(bytes) => {
+
                    doc.extend(bytes);
+
                }
+
            }
+
            ControlFlow::Continue(doc)
+
        });
+

+
        let doc = Automerge::load(&doc)?;
+

+
        Ok(doc)
+
    }
+

+
    /// List objects.
+
    pub fn list(&self) -> Result<Vec<(ObjectId, T)>, 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()
+
    }
+
}
added radicle/src/cob/transaction.rs
@@ -0,0 +1,72 @@
+
use std::ops::{Deref, DerefMut};
+

+
use automerge::transaction::Transactable;
+
use automerge::AutomergeError;
+

+
use crate::cob::value::Value;
+

+
/// Wraps an automerge transaction with additional functionality.
+
#[derive(Debug)]
+
pub struct Transaction<'a, 'b> {
+
    raw: &'a mut automerge::transaction::Transaction<'b>,
+
}
+

+
impl<'a, 'b> AsMut<automerge::transaction::Transaction<'b>> for Transaction<'a, 'b> {
+
    fn as_mut(&mut self) -> &mut automerge::transaction::Transaction<'b> {
+
        self.raw
+
    }
+
}
+

+
impl<'a, 'b> Deref for Transaction<'a, 'b> {
+
    type Target = automerge::transaction::Transaction<'b>;
+

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

+
impl<'a, 'b> DerefMut for Transaction<'a, 'b> {
+
    fn deref_mut(&mut self) -> &mut Self::Target {
+
        self.raw
+
    }
+
}
+

+
impl<'a, 'b> Transaction<'a, 'b> {
+
    pub fn new(raw: &'a mut automerge::transaction::Transaction<'b>) -> Self {
+
        Self { raw }
+
    }
+

+
    pub fn try_get<O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
+
        &self,
+
        obj: O,
+
        prop: P,
+
    ) -> Result<Option<(Value, automerge::ObjId)>, TransactionError> {
+
        let prop = prop.into();
+
        let result = self.raw.get(obj, prop)?;
+

+
        Ok(result)
+
    }
+

+
    pub fn get<O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
+
        &self,
+
        obj: O,
+
        prop: P,
+
    ) -> Result<(Value, automerge::ObjId), TransactionError> {
+
        let prop = prop.into();
+

+
        self.raw
+
            .get(obj, prop.clone())?
+
            .ok_or(TransactionError::PropertyNotFound(prop))
+
    }
+
}
+

+
/// Transaction error.
+
#[derive(thiserror::Error, Debug)]
+
pub enum TransactionError {
+
    #[error(transparent)]
+
    Automerge(#[from] AutomergeError),
+
    #[error("property '{0}' was not found in object")]
+
    PropertyNotFound(automerge::Prop),
+
    #[error("invalid property value for '{0}'")]
+
    InvalidValue(&'static str),
+
}
added radicle/src/cob/value.rs
@@ -0,0 +1,77 @@
+
#![allow(clippy::large_enum_variant)]
+
use std::str::FromStr;
+
use std::sync::Arc;
+

+
pub use automerge::{ScalarValue, Value};
+

+
use crate::git;
+
use crate::prelude::*;
+

+
/// Implemented by types that can be converted from a [`Value`].
+
pub trait FromValue<'a>: Sized {
+
    fn from_value(val: Value<'a>) -> Result<Self, ValueError>;
+
}
+

+
#[derive(thiserror::Error, Debug)]
+
pub enum ValueError {
+
    #[error("invalid type")]
+
    InvalidType,
+
    #[error("invalid value: `{0}`")]
+
    InvalidValue(String),
+
    #[error("value error: {0}")]
+
    Other(Arc<dyn std::error::Error + Send + Sync>),
+
}
+

+
impl<'a, T> FromValue<'a> for Option<T>
+
where
+
    T: FromValue<'a>,
+
{
+
    fn from_value(val: Value<'a>) -> Result<Option<T>, ValueError> {
+
        match val {
+
            Value::Scalar(s) if s.is_null() => Ok(None),
+
            _ => Ok(Some(T::from_value(val)?)),
+
        }
+
    }
+
}
+

+
impl<'a> FromValue<'a> for NodeId {
+
    fn from_value(val: Value<'a>) -> Result<NodeId, ValueError> {
+
        let peer = String::from_value(val)?;
+
        let peer = NodeId::from_str(&peer).map_err(|e| ValueError::Other(Arc::new(e)))?;
+

+
        Ok(peer)
+
    }
+
}
+

+
impl<'a> FromValue<'a> for uuid::Uuid {
+
    fn from_value(val: Value<'a>) -> Result<uuid::Uuid, ValueError> {
+
        let uuid = String::from_value(val)?;
+
        let uuid = uuid::Uuid::from_str(&uuid).map_err(|e| ValueError::Other(Arc::new(e)))?;
+

+
        Ok(uuid)
+
    }
+
}
+

+
impl<'a> FromValue<'a> for Id {
+
    fn from_value(val: Value<'a>) -> Result<Id, ValueError> {
+
        let id = String::from_value(val)?;
+
        let id = Id::from_str(&id).map_err(|e| ValueError::Other(Arc::new(e)))?;
+

+
        Ok(id)
+
    }
+
}
+

+
impl<'a> FromValue<'a> for git::Oid {
+
    fn from_value(val: Value<'a>) -> Result<git::Oid, ValueError> {
+
        let oid = String::from_value(val)?;
+
        let oid = git::Oid::from_str(&oid).map_err(|e| ValueError::Other(Arc::new(e)))?;
+

+
        Ok(oid)
+
    }
+
}
+

+
impl<'a> FromValue<'a> for String {
+
    fn from_value(val: Value) -> Result<String, ValueError> {
+
        val.into_string().map_err(|_| ValueError::InvalidType)
+
    }
+
}
modified radicle/src/profile.rs
@@ -148,7 +148,11 @@ pub struct Paths<'a> {
    home: &'a Path,
}

-
impl Paths<'_> {
+
impl<'a> Paths<'a> {
+
    pub fn new(home: &'a Path) -> Self {
+
        Self { home }
+
    }
+

    pub fn storage(&self) -> PathBuf {
        self.home.join("storage")
    }
modified radicle/src/test.rs
@@ -3,3 +3,27 @@ pub mod arbitrary;
pub mod assert;
pub mod fixtures;
pub mod storage;
+

+
pub mod setup {
+
    use tempfile::TempDir;
+

+
    use crate::crypto::test::signer::MockSigner;
+
    use crate::prelude::*;
+
    use crate::{
+
        profile::Paths,
+
        test::{fixtures, storage::git::Repository},
+
        Storage,
+
    };
+

+
    pub fn context(tmp: &TempDir) -> (Storage, MockSigner, Repository) {
+
        let mut rng = fastrand::Rng::new();
+
        let signer = MockSigner::new(&mut rng);
+
        let home = tmp.path().join("home");
+
        let paths = Paths::new(home.as_path());
+
        let storage = Storage::open(paths.storage()).unwrap();
+
        let (id, _, _, _) = fixtures::project(tmp.path().join("copy"), &storage, &signer).unwrap();
+
        let project = storage.repository(id).unwrap();
+

+
        (storage, signer, project)
+
    }
+
}