Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Implement patches with radicle CRDTs
Alexis Sellier committed 3 years ago
commit 0dc34dc15d596f2118b4eaa63949e8f41fc5c0a2
parent b82e1864b5e788b0a06c39d1b170bdcda2f1eed3
20 files changed +1219 -2103
modified Cargo.lock
@@ -119,26 +119,6 @@ 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"
@@ -347,9 +327,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"

[[package]]
name = "chrono"
-
version = "0.4.22"
+
version = "0.4.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1"
+
checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f"
dependencies = [
 "iana-time-zone",
 "js-sys",
@@ -852,9 +832,9 @@ dependencies = [

[[package]]
name = "ethereum-types"
-
version = "0.14.0"
+
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "81224dc661606574f5a0f28c9947d0ee1d93ff11c5f1c4e7272f52e8c0b5483c"
+
checksum = "02d215cbf040552efcbe99a38372fe80ab9d00268e20012b79fcd0f073edd8ee"
dependencies = [
 "ethbloom",
 "fixed-hash",
@@ -1018,15 +998,6 @@ 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"
@@ -1559,12 +1530,6 @@ dependencies = [
]

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

-
[[package]]
name = "lexical-core"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2200,7 +2165,6 @@ dependencies = [
name = "radicle"
version = "0.2.0"
dependencies = [
-
 "automerge",
 "base64",
 "byteorder",
 "crossbeam-channel",
@@ -2228,7 +2192,6 @@ dependencies = [
 "sqlite",
 "tempfile",
 "thiserror",
-
 "uuid 1.2.2",
 "zeroize",
]

@@ -2909,15 +2872,6 @@ 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 = "snapbox"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3268,9 +3222,9 @@ dependencies = [

[[package]]
name = "tokio-macros"
-
version = "1.8.0"
+
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484"
+
checksum = "d266c00fde287f55d3f1c3e96c500c362a2b8c695076ec180f27918820bc6df8"
dependencies = [
 "proc-macro2",
 "quote",
@@ -3475,9 +3429,9 @@ checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"

[[package]]
name = "uint"
-
version = "0.9.4"
+
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a45526d29728d135c2900b0d30573fe3ee79fceb12ef534c7bb30e810a91b601"
+
checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52"
dependencies = [
 "byteorder",
 "crunchy",
@@ -3530,27 +3484,6 @@ 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.2"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "422ee0de9031b5b948b97a8fc04e3aa35230001a722ddd27943e0be31564ce4c"
-
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-cli/src/commands/inspect.rs
@@ -157,8 +157,10 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            let blob = Doc::blob_at(oid, &repo)?;
            let content: serde_json::Value = serde_json::from_slice(blob.content())?;
            let timezone = if tip.time().sign() == '+' {
+
                #[allow(deprecated)]
                FixedOffset::east(tip.time().offset_minutes() * 60)
            } else {
+
                #[allow(deprecated)]
                FixedOffset::west(tip.time().offset_minutes() * 60)
            };
            let time = DateTime::<Utc>::from(
modified radicle-cli/src/commands/merge.rs
@@ -7,9 +7,8 @@ use anyhow::{anyhow, Context};

use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
-
use radicle::cob::automerge;
use radicle::cob::patch::RevisionIx;
-
use radicle::cob::patch::{Patch, PatchId};
+
use radicle::cob::patch::{Patch, PatchId, Patches};
use radicle::git;
use radicle::prelude::*;
use radicle::rad;
@@ -141,8 +140,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        .project_of(profile.id())
        .context(format!("couldn't load project {} from local state", id))?;
    let repository = profile.storage.repository(id)?;
-
    let cobs = automerge::Store::open(*profile.id(), &repository)?;
-
    let patches = cobs.patches();
+
    let mut patches = Patches::open(*profile.id(), &repository)?;

    if repo.head_detached()? {
        anyhow::bail!("HEAD is in a detached state; can't merge");
@@ -152,9 +150,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    // Get patch information
    //
    let patch_id = options.id;
-
    let patch = patches
-
        .get(&patch_id)?
-
        .ok_or_else(|| anyhow!("couldn't find patch {} locally", &options.id))?;
+
    let mut patch = patches
+
        .get_mut(&patch_id)
+
        .map_err(|e| anyhow!("couldn't find patch {} locally: {e}", &options.id))?;

    let head = repo.head()?;
    let branch = head
@@ -163,11 +161,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let head_oid = head
        .target()
        .ok_or_else(|| anyhow!("cannot merge into detatched head; aborting"))?;
-
    let revision_id = options.revision.unwrap_or_else(|| patch.version());
-
    let revision = patch
-
        .revisions
-
        .get(revision_id)
-
        .ok_or_else(|| anyhow!("revision R{} does not exist", revision_id))?;
+
    let revision_ix = options.revision.unwrap_or_else(|| patch.version());
+
    let (revision_id, revision) = patch
+
        .revisions()
+
        .nth(revision_ix)
+
        .ok_or_else(|| anyhow!("revision R{} does not exist", revision_ix))?;

    //
    // Analyze merge
@@ -234,9 +232,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        "{} {} {} ({}) by {} into {} ({}) via {}...",
        term::format::bold("Merging"),
        term::format::tertiary(term::format::cob(&patch_id)),
-
        term::format::dim(format!("R{}", revision_id)),
+
        term::format::dim(format!("R{}", revision_ix)),
        term::format::secondary(term::format::oid(revision.oid)),
-
        term::format::tertiary(patch.author.id),
+
        term::format::tertiary(patch.author().id),
        term::format::highlight(branch),
        term::format::secondary(term::format::oid(head_oid)),
        merge_style_pretty
@@ -251,7 +249,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    //
    match merge_style {
        MergeStyle::Commit => {
-
            merge_commit(&repo, patch_id, &patch_commit, &patch, cobs.public_key())?;
+
            merge_commit(&repo, patch_id, &patch_commit, &patch, signer.public_key())?;
        }
        MergeStyle::FastForward => {
            fast_forward(&repo, &revision.oid)?;
@@ -270,7 +268,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    // Update patch COB
    //
    // TODO: Don't allow merging the same revision twice?
-
    patches.merge(&patch_id, revision_id, head_oid.into(), &signer)?;
+
    patch.merge(*revision_id, head_oid.into(), &signer)?;

    term::success!(
        "Patch state updated, use {} to publish",
@@ -294,21 +292,21 @@ fn merge_commit(
    patch: &Patch,
    whoami: &PublicKey,
) -> anyhow::Result<()> {
-
    let description = patch.description().trim();
+
    let description = patch.description().unwrap_or_default().trim();
    let mut merge_opts = git::raw::MergeOptions::new();
    let mut merge_msg = format!(
        "Merge patch '{}' from {}",
        term::format::cob(&patch_id),
-
        patch.author.id()
+
        patch.author().id()
    );
    write!(&mut merge_msg, "\n\n")?;

    if !description.is_empty() {
-
        write!(&mut merge_msg, "{}", patch.description().trim())?;
+
        write!(&mut merge_msg, "{}", description)?;
        write!(&mut merge_msg, "\n\n")?;
    }
    writeln!(&mut merge_msg, "Rad-Patch: {}", patch_id)?;
-
    writeln!(&mut merge_msg, "Rad-Author: {}", patch.author.id())?;
+
    writeln!(&mut merge_msg, "Rad-Author: {}", patch.author().id())?;
    writeln!(&mut merge_msg, "Rad-Committer: {}", whoami)?;
    writeln!(&mut merge_msg)?;
    writeln!(&mut merge_msg, "{}", MERGE_HELP_MSG.trim())?;
modified radicle-cli/src/commands/patch/common.rs
@@ -1,4 +1,4 @@
-
use radicle::cob::automerge::patch::{MergeTarget, Patch, PatchId, PatchStore};
+
use radicle::cob::patch::{Clock, MergeTarget, Patch, PatchId, Patches};
use radicle::git;
use radicle::git::raw::Oid;
use radicle::prelude::*;
@@ -111,15 +111,15 @@ pub fn find_unmerged_with_base(
    patch_head: Oid,
    target_head: Oid,
    merge_base: Oid,
-
    patches: &PatchStore,
+
    patches: &Patches,
    workdir: &git::raw::Repository,
-
) -> anyhow::Result<Vec<(PatchId, Patch)>> {
+
) -> anyhow::Result<Vec<(PatchId, Patch, Clock)>> {
    // My patches.
    let proposed: Vec<_> = patches.proposed_by(patches.public_key())?.collect();
    let mut matches = Vec::new();

-
    for (id, patch) in proposed {
-
        let (_, rev) = patch.latest();
+
    for (id, patch, clock) in proposed {
+
        let (_, rev) = patch.latest().unwrap();

        if !rev.merges.is_empty() {
            continue;
@@ -129,7 +129,7 @@ pub fn find_unmerged_with_base(
        }
        // Merge-base between the two patches.
        if workdir.merge_base(**patch.head(), target_head)? == merge_base {
-
            matches.push((id, patch));
+
            matches.push((id, patch, clock));
        }
    }
    Ok(matches)
modified radicle-cli/src/commands/patch/create.rs
@@ -2,8 +2,7 @@ use std::path::Path;

use anyhow::{anyhow, Context};

-
use radicle::cob::automerge;
-
use radicle::cob::automerge::patch::{MergeTarget, Patch, PatchId, PatchStore};
+
use radicle::cob::patch::{MergeTarget, PatchId, PatchMut, Patches};
use radicle::git;
use radicle::git::raw::Oid;
use radicle::prelude::*;
@@ -52,8 +51,7 @@ pub fn run(
    ));

    let signer = term::signer(profile)?;
-
    let cobs = automerge::Store::open(profile.public_key, storage)?;
-
    let patches = cobs.patches();
+
    let mut patches = Patches::open(profile.public_key, storage)?;

    // `HEAD`; This is what we are proposing as a patch.
    let head = workdir.head()?;
@@ -132,17 +130,17 @@ pub fn run(
                workdir,
            )?;

-
            if let Some((id, patch)) = result.pop() {
+
            if let Some((id, patch, clock)) = result.pop() {
                if result.is_empty() {
                    spinner.message(format!(
                        "Found existing patch {} {}",
                        term::format::tertiary(term::format::cob(&id)),
-
                        term::format::italic(&patch.title)
+
                        term::format::italic(patch.title())
                    ));
                    spinner.finish();
                    term::blank();

-
                    Some((id, patch))
+
                    Some((id, PatchMut::new(id, patch, clock, &mut patches)))
                } else {
                    spinner.failed();
                    term::blank();
@@ -155,7 +153,7 @@ pub fn run(
            }
        }
        Update::Patch(id) => {
-
            if let Some(patch) = patches.get(id)? {
+
            if let Ok(patch) = patches.get_mut(id) {
                Some((*id, patch))
            } else {
                anyhow::bail!("Patch `{}` not found", id);
@@ -167,9 +165,7 @@ pub fn run(
        if term::confirm("Update?") {
            term::blank();

-
            return update(
-
                patch, id, &base_oid, &head_oid, &patches, workdir, options, &signer,
-
            );
+
            return update(patch, id, &base_oid, &head_oid, workdir, options, &signer);
        } else {
            anyhow::bail!("Patch update aborted by user");
        }
@@ -183,7 +179,7 @@ pub fn run(
        term::format::dim(target_peer.id),
        term::format::highlight(&project.default_branch.to_string()),
        term::format::secondary(&term::format::oid(*target_oid)),
-
        term::format::dim(term::format::node(cobs.public_key())),
+
        term::format::dim(term::format::node(patches.public_key())),
        term::format::highlight(&head_branch.to_string()),
        term::format::secondary(&term::format::oid(head_oid)),
    );
@@ -237,7 +233,7 @@ pub fn run(
        anyhow::bail!("patch proposal aborted by user");
    }

-
    let id = patches.create(
+
    let patch = patches.create(
        title,
        &description,
        MergeTarget::default(),
@@ -248,7 +244,7 @@ pub fn run(
    )?;

    term::blank();
-
    term::success!("Patch {} created 🌱", term::format::highlight(id));
+
    term::success!("Patch {} created 🌱", term::format::highlight(patch.id));

    if options.sync {
        // TODO
@@ -259,16 +255,17 @@ pub fn run(

/// Update an existing patch with a new revision.
fn update<G: Signer>(
-
    patch: Patch,
+
    mut patch: PatchMut,
    patch_id: PatchId,
    base: &Oid,
    head: &Oid,
-
    patches: &PatchStore,
    workdir: &git::raw::Repository,
    options: Options,
    signer: &G,
) -> anyhow::Result<()> {
-
    let (current, current_revision) = patch.latest();
+
    // TODO(cloudhead): Handle error.
+
    let (_, current_revision) = patch.latest().unwrap();
+
    let current_version = patch.version();

    if *current_revision.oid == *head {
        term::info!("Nothing to do, patch is already up to date.");
@@ -278,9 +275,9 @@ fn update<G: Signer>(
    term::info!(
        "{} {} ({}) -> {} ({})",
        term::format::tertiary(term::format::cob(&patch_id)),
-
        term::format::dim(format!("R{}", current)),
+
        term::format::dim(format!("R{}", current_version)),
        term::format::secondary(term::format::oid(current_revision.oid)),
-
        term::format::dim(format!("R{}", current + 1)),
+
        term::format::dim(format!("R{}", current_version + 1)),
        term::format::secondary(term::format::oid(*head)),
    );
    let message = options.message.get(REVISION_MSG);
@@ -292,9 +289,7 @@ fn update<G: Signer>(
    if !term::confirm("Continue?") {
        anyhow::bail!("patch update aborted by user");
    }
-

-
    let new = patches.update(&patch_id, message, *base, *head, signer)?;
-
    assert_eq!(new, current + 1);
+
    patch.update(message, *base, *head, signer)?;

    term::blank();
    term::success!("Patch {} updated 🌱", term::format::highlight(patch_id));
modified radicle-cli/src/commands/patch/list.rs
@@ -1,5 +1,6 @@
-
use radicle::cob::automerge;
-
use radicle::cob::patch::{Patch, PatchId, Verdict};
+
use anyhow::anyhow;
+

+
use radicle::cob::patch::{Patch, PatchId, Patches, Verdict};
use radicle::git;
use radicle::prelude::*;
use radicle::profile::Profile;
@@ -22,8 +23,7 @@ pub fn run(
    }

    let me = *profile.id();
-
    let cobs = automerge::Store::open(*profile.id(), storage)?;
-
    let patches = cobs.patches();
+
    let patches = Patches::open(*profile.id(), storage)?;
    let proposed = patches.proposed()?;

    // Patches the user authored.
@@ -31,8 +31,8 @@ pub fn run(
    // Patches other users authored.
    let mut other = Vec::new();

-
    for (id, patch) in proposed {
-
        if *patch.author.id() == me {
+
    for (id, patch, _) in proposed {
+
        if *patch.author().id() == me {
            own.push((id, patch));
        } else {
            other.push((id, patch));
@@ -61,7 +61,7 @@ pub fn run(
        for (id, patch) in &mut other {
            term::blank();

-
            print(cobs.public_key(), id, patch, &workdir, storage)?;
+
            print(patches.public_key(), id, patch, &workdir, storage)?;
        }
    }
    term::blank();
@@ -77,25 +77,29 @@ fn print(
    workdir: &Option<git::raw::Repository>,
    storage: &Repository,
) -> anyhow::Result<()> {
-
    let target_head = common::patch_merge_target_oid(patch.target, storage)?;
+
    let target_head = common::patch_merge_target_oid(patch.target(), storage)?;

-
    let you = patch.author.id() == whoami;
+
    let you = patch.author().id() == whoami;
    let prefix = "└─ ";
    let mut author_info = vec![format!(
        "{}* opened by {}",
        prefix,
-
        term::format::tertiary(patch.author.id()),
+
        term::format::tertiary(patch.author().id()),
    )];

    if you {
        author_info.push(term::format::secondary("(you)"));
    }
-
    author_info.push(term::format::dim(term::format::timestamp(&patch.timestamp)));
+
    author_info.push(term::format::dim(term::format::timestamp(
+
        &patch.timestamp(),
+
    )));

-
    let revision = patch.revisions.last();
+
    let (_, revision) = patch
+
        .latest()
+
        .ok_or_else(|| anyhow!("patch is malformed: no revisions found"))?;
    term::info!(
        "{} {} {} {} {}",
-
        term::format::bold(&patch.title),
+
        term::format::bold(patch.title()),
        term::format::highlight(term::format::cob(patch_id)),
        term::format::dim(format!("R{}", patch.version())),
        common::pretty_commit_version(&revision.oid, workdir)?,
@@ -104,7 +108,7 @@ fn print(
    term::info!("{}", author_info.join(" "));

    let mut timeline = Vec::new();
-
    for merge in &revision.merges {
+
    for merge in revision.merges.iter() {
        let peer = storage.remote(&merge.node)?;
        let mut badges = Vec::new();

@@ -126,13 +130,13 @@ fn print(
            ),
        ));
    }
-
    for review in revision.reviews.values() {
-
        let verdict = match review.verdict {
+
    for (reviewer, review) in revision.reviews.iter() {
+
        let verdict = match review.verdict() {
            Some(Verdict::Accept) => term::format::positive(term::format::dim("✓ accepted")),
            Some(Verdict::Reject) => term::format::negative(term::format::dim("✗ rejected")),
            None => term::format::negative(term::format::dim("⋄ reviewed")),
        };
-
        let peer = storage.remote(review.author.id())?;
+
        let peer = storage.remote(reviewer)?;
        let mut badges = Vec::new();

        if peer.delegate {
@@ -143,12 +147,12 @@ fn print(
        }

        timeline.push((
-
            review.timestamp,
+
            review.timestamp(),
            format!(
                "{}{} by {} {}",
                " ".repeat(term::text_width(prefix)),
                verdict,
-
                term::format::tertiary(review.author.id()),
+
                term::format::tertiary(reviewer),
                badges.join(" "),
            ),
        ));
modified radicle-cli/src/commands/review.rs
@@ -3,8 +3,7 @@ use std::str::FromStr;

use anyhow::{anyhow, Context};

-
use radicle::cob;
-
use radicle::cob::automerge::patch::{PatchId, RevisionIx, Verdict};
+
use radicle::cob::patch::{PatchId, Patches, RevisionIx, Verdict};
use radicle::prelude::*;
use radicle::rad;

@@ -140,18 +139,17 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let _project = repository
        .project_of(profile.id())
        .context(format!("couldn't load project {} from local state", id))?;
-
    let cobs = cob::automerge::Store::open(*profile.id(), &repository)?;
-
    let patches = cobs.patches();
+
    let mut patches = Patches::open(*profile.id(), &repository)?;

    let patch_id = options.id;
-
    let patch = patches
-
        .get(&patch_id)?
+
    let mut patch = patches
+
        .get_mut(&patch_id)
        .context(format!("couldn't find patch {} locally", patch_id))?;
    let patch_id_pretty = term::format::tertiary(term::format::cob(&patch_id));
    let revision_ix = options.revision.unwrap_or_else(|| patch.version());
-
    let _revision = patch
-
        .revisions
-
        .get(revision_ix)
+
    let (revision_id, _) = patch
+
        .revisions()
+
        .nth(revision_ix)
        .ok_or_else(|| anyhow!("revision R{} does not exist", revision_ix))?;
    let message = options.message.get(REVIEW_HELP_MSG);

@@ -165,16 +163,15 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        verdict_pretty,
        patch_id_pretty,
        term::format::dim(format!("R{}", revision_ix)),
-
        term::format::tertiary(patch.author.id())
+
        term::format::tertiary(patch.author().id())
    )) {
        anyhow::bail!("Patch review aborted");
    }

-
    patches.review(
-
        &patch_id,
-
        revision_ix,
+
    patch.review(
+
        *revision_id,
        options.verdict,
-
        message,
+
        Some(message),
        vec![],
        &signer,
    )?;
modified radicle/Cargo.toml
@@ -11,7 +11,6 @@ test = ["quickcheck", "radicle-crypto/test"]
sql = ["sqlite"]

[dependencies]
-
automerge = { version = "0.1" }
base64 = { version= "0.13" }
byteorder = { version = "1.4" }
crossbeam-channel = { version = "0.5.6" }
@@ -31,7 +30,6 @@ 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,4 +1,3 @@
-
pub mod automerge;
pub mod common;
pub mod issue;
pub mod patch;
deleted radicle/src/cob/automerge.rs
@@ -1,9 +0,0 @@
-
pub mod doc;
-
pub mod label;
-
pub mod patch;
-
pub mod shared;
-
pub mod store;
-
pub mod transaction;
-
pub mod value;
-

-
pub use store::Store;
deleted radicle/src/cob/automerge/doc.rs
@@ -1,243 +0,0 @@
-
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::automerge::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
-
    }
-
}
deleted radicle/src/cob/automerge/label.rs
@@ -1,174 +0,0 @@
-
#![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::automerge::doc::Document;
-
use crate::cob::automerge::shared::FromHistory;
-
use crate::cob::automerge::store::{Error, Store};
-
use crate::cob::automerge::transaction::TransactionError;
-
use crate::cob::common::*;
-
use crate::cob::{Contents, History, ObjectId, Timestamp, 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| {
-
            let bytes = entry.contents();
-
            match automerge::Change::from_bytes(bytes.clone()) {
-
                Ok(change) => {
-
                    doc.apply_changes([change]).ok();
-
                }
-
                Err(_err) => {
-
                    // Ignore
-
                }
-
            }
-
            ControlFlow::Continue(doc)
-
        });
-
        let 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(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");
-
    }
-
}
deleted radicle/src/cob/automerge/patch.rs
@@ -1,835 +0,0 @@
-
#![allow(clippy::too_many_arguments)]
-
use std::collections::{HashMap, HashSet};
-
use std::convert::TryFrom;
-
use std::ops::{ControlFlow, Deref};
-
use std::sync::Arc;
-

-
use automerge::transaction::Transactable;
-
use automerge::{Automerge, AutomergeError, ObjType, ScalarValue, Value};
-
use nonempty::NonEmpty;
-

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

-
// Re-export generic patch.
-
pub use crate::cob::patch::*;
-

-
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<Tag> = 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,
-
        })
-
    }
-
}
-

-
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: &[Tag],
-
        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))
-
    }
-
}
-

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

-
impl Revision {
-
    /// 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(())
-
    }
-
}
-

-
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)))
-
    }
-
}
-

-
impl Review {
-
    /// 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(())
-
    }
-
}
-

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

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

-
        Ok(patch)
-
    }
-
}
-

-
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: &[Tag],
-
    ) -> 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(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(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, 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(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(((), 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(change)
-
    }
-
}
-

-
#[cfg(test)]
-
mod test {
-
    use std::str::FromStr;
-

-
    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.");
-
    }
-
}
deleted radicle/src/cob/automerge/shared.rs
@@ -1,185 +0,0 @@
-
#![allow(clippy::large_enum_variant)]
-
use std::borrow::Borrow;
-
use std::collections::HashMap;
-
use std::str::FromStr;
-

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

-
use crate::cob::automerge::doc::{Document, DocumentError};
-
use crate::cob::automerge::store::Error;
-
use crate::cob::automerge::value::{FromValue, Value, ValueError};
-
use crate::cob::common::*;
-
use crate::cob::{History, TypeName};
-

-
/// 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>;
-
}
-

-
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)
-
    }
-
}
-

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

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

-
impl From<Timestamp> for ScalarValue {
-
    fn from(ts: Timestamp) -> Self {
-
        ScalarValue::Timestamp(ts.as_secs() 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::new(*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();
-
    }
-
}
deleted radicle/src/cob/automerge/store.rs
@@ -1,189 +0,0 @@
-
//! 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::automerge::doc::DocumentError;
-
use crate::cob::automerge::shared::FromHistory;
-
use crate::cob::automerge::transaction::TransactionError;
-
use crate::cob::automerge::{label, patch};
-
use crate::cob::common::Author;
-
use crate::cob::CollaborativeObject;
-
use crate::cob::{Contents, Create, HistoryType, 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),
-
}
-

-
/// 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 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)
-
    }
-

-
    /// Get the public key associated with this store.
-
    pub fn public_key(&self) -> &PublicKey {
-
        &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,
-
                history_type: HistoryType::Automerge,
-
                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())),
-
                history_type: HistoryType::Automerge,
-
                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| {
-
            doc.extend(entry.contents());
-
            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()
-
    }
-
}
deleted radicle/src/cob/automerge/transaction.rs
@@ -1,72 +0,0 @@
-
use std::ops::{Deref, DerefMut};
-

-
use automerge::transaction::Transactable;
-
use automerge::AutomergeError;
-

-
use crate::cob::automerge::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),
-
}
deleted radicle/src/cob/automerge/value.rs
@@ -1,97 +0,0 @@
-
#![allow(clippy::large_enum_variant)]
-
use std::str::FromStr;
-
use std::sync::Arc;
-

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

-
use crate::cob::patch::*;
-
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)
-
    }
-
}
-

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

-
impl From<MergeTarget> for ScalarValue {
-
    fn from(target: MergeTarget) -> Self {
-
        match target {
-
            MergeTarget::Delegates => ScalarValue::from("delegates"),
-
        }
-
    }
-
}
modified radicle/src/cob/common.rs
@@ -1,4 +1,3 @@
-
use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -168,56 +167,47 @@ impl FromStr for Color {
    }
}

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

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

-
/// 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 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 From<usize> for CommentId {
-
    fn from(ix: usize) -> Self {
-
        Self { ix }
+
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)
    }
}

-
impl From<CommentId> for usize {
-
    fn from(id: CommentId) -> Self {
-
        id.ix
-
    }
-
}
+
#[cfg(test)]
+
mod test {
+
    use super::*;

-
#[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,
-
}
+
    #[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);

-
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,
-
        }
+
        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();
    }
}
modified radicle/src/cob/patch.rs
@@ -1,157 +1,404 @@
-
use std::collections::{HashMap, HashSet};
+
#![allow(clippy::too_many_arguments)]
+
use std::collections::BTreeMap;
+
use std::collections::VecDeque;
use std::fmt;
-
use std::ops::RangeInclusive;
+
use std::ops::ControlFlow;
+
use std::ops::Deref;
+
use std::ops::Range;
use std::str::FromStr;

-
use nonempty::NonEmpty;
use once_cell::sync::Lazy;
+
use radicle_crdt as crdt;
+
use radicle_crdt::clock;
+
use radicle_crdt::{ActorId, ChangeId, LWWMap, LWWReg, LWWSet, Max, Redactable, Semilattice};
use serde::{Deserialize, Serialize};
+
use thiserror::Error;

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

+
/// The logical clock we use to order changes to patches.
+
pub use clock::Lamport as Clock;

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

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

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

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

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

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

+
/// Patch operation.
+
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+
pub enum Action {
+
    Edit {
+
        title: String,
+
        description: String,
+
        target: MergeTarget,
+
    },
+
    Tag {
+
        add: Vec<Tag>,
+
        remove: Vec<Tag>,
+
    },
+
    Revision {
+
        base: git::Oid,
+
        oid: git::Oid,
+
    },
+
    Review {
+
        revision: RevisionId,
+
        comment: Option<String>,
+
        verdict: Option<Verdict>,
+
        inline: Vec<CodeComment>,
+
    },
+
    Merge {
+
        revision: RevisionId,
+
        commit: git::Oid,
+
    },
+
    Thread {
+
        revision: RevisionId,
+
        action: thread::Action,
+
    },
+
}
+

+
impl Action {
+
    pub fn decode(bytes: &[u8]) -> Result<Self, serde_json::Error> {
+
        serde_json::from_slice(bytes)
+
    }
+
}
+

/// Where a patch is intended to be merged.
-
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[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.
+
    /// If it were otherwise, patches could become un-mergeable.
    #[default]
    Delegates,
}

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

+
impl Semilattice for Patch {
+
    fn merge(&mut self, other: Self) {
+
        self.title.merge(other.title);
+
        self.description.merge(other.description);
+
        self.status.merge(other.status);
+
        self.target.merge(other.target);
+
        self.tags.merge(other.tags);
+
        self.revisions.merge(other.revisions);
+
    }
+
}
+

+
impl Default for Patch {
+
    fn default() -> Self {
+
        Self {
+
            title: Max::from(String::default()).into(),
+
            description: Max::from(String::default()).into(),
+
            status: Max::from(Status::default()).into(),
+
            target: Max::from(MergeTarget::default()).into(),
+
            tags: LWWSet::default(),
+
            revisions: BTreeMap::default(),
+
        }
+
    }
}

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

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

+
    pub fn target(&self) -> MergeTarget {
+
        *self.target.get().get()
+
    }
+

+
    pub fn timestamp(&self) -> Timestamp {
+
        self.revisions()
+
            .next()
+
            .map(|(_, r)| r)
+
            .expect("Patch::timestamp: at least one revision is present")
+
            .timestamp
+
    }
+

+
    pub fn description(&self) -> Option<&str> {
+
        Some(self.description.get().get())
+
    }
+

+
    pub fn author(&self) -> &Author {
+
        &self
+
            .revisions()
+
            .next()
+
            .map(|(_, r)| r)
+
            .expect("Patch::author: at least one revision is present")
+
            .author
+
    }
+

+
    pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (&RevisionId, &Revision)> {
+
        self.revisions
+
            .iter()
+
            .filter_map(|(rid, r)| -> Option<(&RevisionId, &Revision)> {
+
                r.get().map(|r| (rid, r))
+
            })
+
    }
+

    pub fn head(&self) -> &git::Oid {
-
        &self.revisions.last().oid
+
        &self
+
            .latest()
+
            .map(|(_, r)| r)
+
            .expect("Patch::head: at least one revision is present")
+
            .oid
    }

    pub fn version(&self) -> RevisionIx {
-
        self.revisions.len() - 1
+
        self.revisions
+
            .len()
+
            .checked_sub(1)
+
            .expect("Patch::version: at least one revision is present")
    }

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

-
        (version, revision)
+
    pub fn latest(&self) -> Option<(&RevisionId, &Revision)> {
+
        self.revisions().next_back()
    }

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

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

-
    pub fn description(&self) -> &str {
-
        self.latest().1.description()
+
    /// Apply a list of changes to the state.
+
    pub fn apply(
+
        &mut self,
+
        changes: impl IntoIterator<Item = Change>,
+
        waiting: &mut BTreeMap<ChangeId, Vec<Change>>,
+
    ) -> Result<(), Error> {
+
        let mut queue = changes.into_iter().collect::<VecDeque<_>>();
+

+
        while let Some(change) = queue.pop_front() {
+
            let id = change.id();
+
            self.apply_one(change, waiting)?;
+

+
            // If we have changes waiting for the change we just applied, we can now process them.
+
            if let Some(waiting) = waiting.remove(&id) {
+
                queue.extend(waiting);
+
            }
+
        }
+
        Ok(())
+
    }
+

+
    /// Apply a single change to the state.
+
    pub fn apply_one(
+
        &mut self,
+
        change: Change,
+
        waiting: &mut BTreeMap<ChangeId, Vec<Change>>,
+
    ) -> Result<(), Error> {
+
        let id = change.id();
+
        let author = Author::new(change.author);
+
        // FIXME(cloudhead): Use commit timestamp.
+
        let timestamp = Timestamp::default();
+

+
        match change.action {
+
            Action::Edit {
+
                title,
+
                description,
+
                target,
+
            } => {
+
                self.title.set(title, change.clock);
+
                self.description.set(description, change.clock);
+
                self.target.set(target, change.clock);
+
            }
+
            Action::Tag { add, remove } => {
+
                for tag in add {
+
                    self.tags.insert(tag, change.clock);
+
                }
+
                for tag in remove {
+
                    self.tags.remove(tag, change.clock);
+
                }
+
            }
+
            Action::Revision { base, oid } => {
+
                self.revisions.insert(
+
                    id,
+
                    Redactable::Present(Revision::new(author, base, oid, timestamp)),
+
                );
+
            }
+
            Action::Review {
+
                revision,
+
                ref comment,
+
                verdict,
+
                ref inline,
+
            } => {
+
                // TODO(cloudhead): Test review on redacted revision.
+
                // TODO(cloudhead): Test that updating a review only requires the fields that we
+
                // want to update.
+
                if let Some(Redactable::Present(revision)) = self.revisions.get_mut(&revision) {
+
                    revision.reviews.insert(
+
                        change.author,
+
                        Review::new(verdict, comment.to_owned(), inline.to_owned(), timestamp),
+
                        change.clock,
+
                    );
+
                } else {
+
                    waiting.entry(revision).or_default().push(change);
+
                }
+
            }
+
            Action::Merge { revision, commit } => {
+
                if let Some(Redactable::Present(revision)) = self.revisions.get_mut(&revision) {
+
                    revision.merges.insert(
+
                        Merge {
+
                            node: change.author,
+
                            commit,
+
                            timestamp,
+
                        }
+
                        .into(),
+
                        change.clock,
+
                    );
+
                } else {
+
                    waiting.entry(revision).or_default().push(change);
+
                }
+
            }
+
            Action::Thread { revision, action } => {
+
                // TODO(cloudhead): Make sure we can deal with redacted revisions which are added
+
                // to out of order, like in the `Merge` case.
+
                if let Some(Redactable::Present(revision)) = self.revisions.get_mut(&revision) {
+
                    revision.discussion.apply([crdt::Change {
+
                        action,
+
                        author: change.author,
+
                        clock: change.clock,
+
                    }]);
+
                }
+
            }
+
        }
+
        Ok(())
    }
}

-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
-
#[serde(rename_all = "lowercase")]
-
pub enum State {
-
    Draft,
-
    Proposed,
-
    Archived,
+
impl store::FromHistory for Patch {
+
    type Action = Action;
+

+
    fn type_name() -> &'static TypeName {
+
        &*TYPENAME
+
    }
+

+
    fn from_history(
+
        history: &radicle_cob::History,
+
    ) -> Result<(Self, clock::Lamport), store::Error> {
+
        let mut waiting = BTreeMap::default();
+
        let obj = history.traverse(Self::default(), |mut acc, entry| {
+
            if let Ok(action) = Action::decode(entry.contents()) {
+
                if let Err(err) = acc.apply(
+
                    [Change {
+
                        action,
+
                        author: *entry.actor(),
+
                        clock: entry.clock().into(),
+
                    }],
+
                    &mut waiting,
+
                ) {
+
                    log::warn!("Error applying change to patch state: {err}");
+
                    return ControlFlow::Break(acc);
+
                }
+
            } else {
+
                return ControlFlow::Break(acc);
+
            }
+
            ControlFlow::Continue(acc)
+
        });
+

+
        Ok((obj, history.clock().into()))
+
    }
}

/// 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).
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct Revision {
+
    /// Author of the revision.
+
    pub author: Author,
+
    /// Base branch commit, used as a 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>,
+
    pub discussion: Thread,
    /// Merges of this revision into other repositories.
-
    pub merges: Vec<Merge>,
-
    /// Code changeset for this revision.
-
    pub changeset: T,
+
    pub merges: LWWSet<Max<Merge>>,
+
    /// Reviews of this revision's changes (one per actor).
+
    pub reviews: LWWMap<ActorId, Review>,
    /// 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 {
+
    pub fn new(author: Author, base: git::Oid, oid: git::Oid, timestamp: Timestamp) -> Self {
        Self {
-
            id: uuid::Uuid::new_v4(),
+
            author,
            base,
            oid,
-
            comment: Comment::new(author, comment, timestamp),
-
            discussion: Discussion::default(),
-
            reviews: HashMap::default(),
-
            merges: Vec::default(),
-
            changeset: (),
+
            discussion: Thread::default(),
+
            merges: LWWSet::default(),
+
            reviews: LWWMap::default(),
            timestamp,
        }
    }

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

-
    pub fn author(&self) -> &Author {
-
        &self.comment.author
-
    }
+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
#[serde(rename_all = "lowercase")]
+
pub enum Status {
+
    #[default]
+
    Proposed,
+
    Draft,
+
    Archived,
}

/// A merged patch revision.
-
#[derive(Debug, Clone, Serialize, Deserialize)]
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Merge {
    /// Owner of repository that this patch was merged into.
    pub node: NodeId,
@@ -162,7 +409,7 @@ pub struct Merge {
}

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

+
impl Semilattice for Verdict {
+
    fn merge(&mut self, other: Self) {
+
        if self == &Self::Accept && other == Self::Reject {
+
            *self = other;
+
        }
+
    }
+
}
+

impl fmt::Display for Verdict {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
@@ -181,56 +436,631 @@ impl fmt::Display for Verdict {
}

/// Code location, used for attaching comments.
-
#[derive(Debug, Clone, Serialize, Deserialize)]
+
#[derive(Debug, Clone, PartialEq, Eq, 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,
+
    /// Commit commented on.
+
    pub commit: git::Oid,
+
    /// Line range commented on.
+
    pub lines: Range<usize>,
+
}
+

+
impl PartialOrd for CodeLocation {
+
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+
        Some(self.cmp(other))
+
    }
+
}
+

+
impl Ord for CodeLocation {
+
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+
        (&self.blob, &self.commit, &self.lines.start, &self.lines.end).cmp(&(
+
            &other.blob,
+
            &other.commit,
+
            &other.lines.start,
+
            &other.lines.end,
+
        ))
+
    }
}

/// Comment on code.
-
#[derive(Debug, Clone, Serialize, Deserialize)]
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct CodeComment {
    /// Code location of the comment.
-
    location: CodeLocation,
+
    pub location: CodeLocation,
    /// Comment.
-
    comment: Comment,
+
    pub comment: String,
+
    /// Timestamp.
+
    pub timestamp: Timestamp,
}

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

+
impl Semilattice for Review {
+
    fn merge(&mut self, other: Self) {
+
        self.verdict.merge(other.verdict);
+
        self.comment.merge(other.comment);
+
        self.inline.merge(other.inline);
+
        self.timestamp.merge(other.timestamp);
+
    }
}

impl Review {
    pub fn new(
-
        author: Author,
        verdict: Option<Verdict>,
-
        comment: impl Into<String>,
+
        comment: Option<String>,
        inline: Vec<CodeComment>,
        timestamp: Timestamp,
    ) -> Self {
-
        let comment = Comment::new(author.clone(), comment.into(), timestamp);
+
        Self {
+
            verdict: LWWReg::from(verdict),
+
            comment: LWWReg::from(comment.map(Max::from)),
+
            inline: LWWSet::from_iter(
+
                inline
+
                    .into_iter()
+
                    .map(Max::from)
+
                    .zip(std::iter::repeat(clock::Lamport::default())),
+
            ),
+
            timestamp: Max::from(timestamp),
+
        }
+
    }
+

+
    pub fn verdict(&self) -> Option<Verdict> {
+
        self.verdict.get().as_ref().copied()
+
    }

+
    pub fn comment(&self) -> Option<&str> {
+
        self.comment.get().as_ref().map(|m| m.get().as_str())
+
    }
+

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

+
pub struct PatchMut<'a, 'g> {
+
    pub id: ObjectId,
+

+
    patch: Patch,
+
    clock: clock::Lamport,
+
    store: &'g mut Patches<'a>,
+
}
+

+
impl<'a, 'g> PatchMut<'a, 'g> {
+
    pub fn new(
+
        id: ObjectId,
+
        patch: Patch,
+
        clock: clock::Lamport,
+
        store: &'g mut Patches<'a>,
+
    ) -> Self {
        Self {
-
            author,
-
            verdict,
+
            id,
+
            clock,
+
            patch,
+
            store,
+
        }
+
    }
+

+
    /// Get the internal logical clock.
+
    pub fn clock(&self) -> &clock::Lamport {
+
        &self.clock
+
    }
+

+
    /// Edit patch metadata.
+
    pub fn edit<G: Signer>(
+
        &mut self,
+
        title: String,
+
        description: String,
+
        target: MergeTarget,
+
        signer: &G,
+
    ) -> Result<ChangeId, Error> {
+
        let action = Action::Edit {
+
            title,
+
            description,
+
            target,
+
        };
+
        self.apply("Edit", action, signer)
+
    }
+

+
    /// Comment on a patch revision.
+
    pub fn comment<G: Signer, S: Into<String>>(
+
        &mut self,
+
        revision: RevisionId,
+
        body: S,
+
        signer: &G,
+
    ) -> Result<CommentId, Error> {
+
        let body = body.into();
+
        let action = Action::Thread {
+
            revision,
+
            action: thread::Action::Comment {
+
                body,
+
                reply_to: None,
+
            },
+
        };
+
        self.apply("Comment", action, signer)
+
    }
+

+
    /// Review a patch revision.
+
    pub fn review<G: Signer>(
+
        &mut self,
+
        revision: RevisionId,
+
        verdict: Option<Verdict>,
+
        comment: Option<String>,
+
        inline: Vec<CodeComment>,
+
        signer: &G,
+
    ) -> Result<ChangeId, Error> {
+
        let action = Action::Review {
+
            revision,
            comment,
+
            verdict,
            inline,
-
            timestamp,
+
        };
+
        self.apply("Review patch", action, signer)
+
    }
+

+
    /// Merge a patch revision.
+
    pub fn merge<G: Signer>(
+
        &mut self,
+
        revision: RevisionId,
+
        commit: git::Oid,
+
        signer: &G,
+
    ) -> Result<ChangeId, Error> {
+
        let action = Action::Merge { revision, commit };
+
        self.apply("Merge revision", action, signer)
+
    }
+

+
    /// Update a patch with a new revision.
+
    pub fn update<G: Signer>(
+
        &mut self,
+
        description: impl Into<String>,
+
        base: impl Into<git::Oid>,
+
        oid: impl Into<git::Oid>,
+
        signer: &G,
+
    ) -> Result<ChangeId, Error> {
+
        let description = description.into();
+
        let base = base.into();
+
        let oid = oid.into();
+
        let revision = self.apply(
+
            "Update patch with new revision",
+
            Action::Revision { base, oid },
+
            signer,
+
        )?;
+
        self.comment(revision, description, signer)?;
+

+
        Ok(revision)
+
    }
+

+
    /// Tag a patch.
+
    pub fn tag<G: Signer>(
+
        &mut self,
+
        add: impl IntoIterator<Item = Tag>,
+
        remove: impl IntoIterator<Item = Tag>,
+
        signer: &G,
+
    ) -> Result<ChangeId, Error> {
+
        let add = add.into_iter().collect::<Vec<_>>();
+
        let remove = remove.into_iter().collect::<Vec<_>>();
+
        let action = Action::Tag { add, remove };
+

+
        self.apply("Tag", action, signer)
+
    }
+

+
    /// Apply a change to the patch.
+
    pub fn apply<G: Signer>(
+
        &mut self,
+
        msg: &'static str,
+
        action: Action,
+
        signer: &G,
+
    ) -> Result<ChangeId, Error> {
+
        let mut waiting = BTreeMap::default();
+
        let change = Change {
+
            author: *signer.public_key(),
+
            action: action.clone(),
+
            clock: self.clock.tick(),
+
        };
+

+
        self.patch.apply([change], &mut waiting)?;
+

+
        if !waiting.is_empty() {
+
            return Err(Error::Apply);
+
        }
+
        let cob = self
+
            .store
+
            .update(self.id, msg, action, signer)
+
            .map_err(Error::Store)?;
+
        let clock = cob.history().clock();
+

+
        Ok((clock.into(), *signer.public_key()))
+
    }
+
}
+

+
impl<'a, 'g> Deref for PatchMut<'a, 'g> {
+
    type Target = Patch;
+

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

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

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

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

+
impl<'a> Patches<'a> {
+
    /// Open an patches store.
+
    pub fn open(
+
        whoami: PublicKey,
+
        repository: &'a storage::Repository,
+
    ) -> Result<Self, store::Error> {
+
        let raw = store::Store::open(whoami, repository)?;
+

+
        Ok(Self { raw })
+
    }
+

+
    /// Create a patch.
+
    pub fn create<'g, G: Signer>(
+
        &'g mut self,
+
        title: impl Into<String>,
+
        description: impl Into<String>,
+
        target: MergeTarget,
+
        base: impl Into<git::Oid>,
+
        oid: impl Into<git::Oid>,
+
        tags: &[Tag],
+
        signer: &G,
+
    ) -> Result<PatchMut<'a, 'g>, Error> {
+
        let title = title.into();
+
        let description = description.into();
+
        let action = Action::Revision {
+
            base: base.into(),
+
            oid: oid.into(),
+
        };
+
        let (id, patch, clock) = self.raw.create("Create patch", action, signer)?;
+
        let mut patch = PatchMut::new(id, patch, clock, self);
+

+
        patch.edit(title, description, target, signer)?;
+
        patch.tag(tags.to_owned(), [], signer)?;
+

+
        Ok(patch)
+
    }
+

+
    /// Get an issue.
+
    pub fn get(&self, id: &ObjectId) -> Result<Option<Patch>, store::Error> {
+
        self.raw.get(id).map(|r| r.map(|(p, _)| p))
+
    }
+

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

+
        Ok(PatchMut {
+
            id: *id,
+
            clock,
+
            patch,
+
            store: self,
+
        })
+
    }
+

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

+
        Ok(all
+
            .into_iter()
+
            .filter_map(|result| result.ok())
+
            .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, clock::Lamport)> + '_, Error> {
+
        Ok(self
+
            .proposed()?
+
            .filter(move |(_, p, _)| p.author().id() == who))
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use std::str::FromStr;
+
    use std::{array, iter};
+

+
    use crdt::test::{assert_laws, WeightedGenerator};
+
    use crdt::ActorId;
+

+
    use pretty_assertions::assert_eq;
+
    use quickcheck::Arbitrary;
+
    use quickcheck_macros::quickcheck;
+

+
    use super::*;
+
    use crate::test;
+

+
    #[derive(Clone)]
+
    struct Changes<const N: usize> {
+
        permutations: [Vec<Change>; N],
+
    }
+

+
    impl<const N: usize> std::fmt::Debug for Changes<N> {
+
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
            for (i, p) in self.permutations.iter().enumerate() {
+
                writeln!(
+
                    f,
+
                    "{i}: {:#?}",
+
                    p.iter().map(|c| &c.action).collect::<Vec<_>>()
+
                )?;
+
            }
+
            Ok(())
        }
    }
+

+
    impl<const N: usize> Arbitrary for Changes<N> {
+
        fn arbitrary(g: &mut quickcheck::Gen) -> Self {
+
            type State = (clock::Lamport, Vec<ChangeId>);
+

+
            let author = ActorId::from([0; 32]);
+
            let rng = fastrand::Rng::with_seed(u64::arbitrary(g));
+
            let oids = iter::repeat_with(|| {
+
                git::Oid::try_from(
+
                    iter::repeat_with(|| rng.u8(..))
+
                        .take(20)
+
                        .collect::<Vec<_>>()
+
                        .as_slice(),
+
                )
+
                .unwrap()
+
            })
+
            .take(16)
+
            .collect::<Vec<_>>();
+

+
            let gen = WeightedGenerator::<(clock::Lamport, Action), State>::new(rng.clone())
+
                .variant(1, |(clock, _), rng| {
+
                    Some((
+
                        clock.tick(),
+
                        Action::Edit {
+
                            title: iter::repeat_with(|| rng.alphabetic()).take(8).collect(),
+
                            description: iter::repeat_with(|| rng.alphabetic()).take(16).collect(),
+
                            target: MergeTarget::Delegates,
+
                        },
+
                    ))
+
                })
+
                .variant(1, |(clock, revisions), rng| {
+
                    if revisions.is_empty() {
+
                        return None;
+
                    }
+
                    let revision = revisions[rng.usize(..revisions.len())];
+
                    let commit = oids[rng.usize(..oids.len())];
+

+
                    Some((clock.tick(), Action::Merge { revision, commit }))
+
                })
+
                .variant(1, |(clock, revisions), rng| {
+
                    let oid = oids[rng.usize(..oids.len())];
+
                    let base = oids[rng.usize(..oids.len())];
+

+
                    revisions.push((clock.tick(), author));
+

+
                    Some((*clock, Action::Revision { base, oid }))
+
                });
+

+
            let mut changes = Vec::new();
+
            let mut permutations: [Vec<Change>; N] = array::from_fn(|_| Vec::new());
+

+
            for (clock, action) in gen.take(g.size().min(8)) {
+
                changes.push(Change {
+
                    action,
+
                    author,
+
                    clock,
+
                });
+
            }
+

+
            for p in &mut permutations {
+
                *p = changes.clone();
+
                rng.shuffle(&mut changes);
+
            }
+

+
            Changes { permutations }
+
        }
+
    }
+

+
    // TODO: Test merging of redacted revision
+

+
    #[quickcheck]
+
    fn prop_invariants(log: Changes<3>) {
+
        let t = Patch::default();
+
        let [p1, p2, p3] = log.permutations;
+
        let mut waiting = BTreeMap::default();
+

+
        let mut t1 = t.clone();
+
        t1.apply(p1, &mut waiting).unwrap();
+

+
        waiting.clear();
+

+
        let mut t2 = t.clone();
+
        t2.apply(p2, &mut waiting).unwrap();
+

+
        waiting.clear();
+

+
        let mut t3 = t;
+
        t3.apply(p3, &mut waiting).unwrap();
+

+
        assert_eq!(t1, t2);
+
        assert_eq!(t2, t3);
+
        assert_laws(&t1, &t2, &t3);
+
        assert!(waiting.is_empty());
+
    }
+

+
    #[test]
+
    fn test_patch_create_and_get() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        let mut patches = Patches::open(*signer.public_key(), &project).unwrap();
+
        let author = *signer.public_key();
+
        let target = MergeTarget::Delegates;
+
        let oid = git::Oid::from_str("e2a85016a458cd809c0ecee81f8c99613b0b0945").unwrap();
+
        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
+
        let patch = patches
+
            .create(
+
                "My first patch",
+
                "Blah blah blah.",
+
                target,
+
                base,
+
                oid,
+
                &[],
+
                &signer,
+
            )
+
            .unwrap();
+

+
        let id = patch.id;
+
        let patch = patches.get(&id).unwrap().unwrap();
+

+
        assert_eq!(patch.title(), "My first patch");
+
        assert_eq!(patch.description(), Some("Blah blah blah."));
+
        assert_eq!(patch.author().id(), &author);
+
        assert_eq!(patch.status(), Status::Proposed);
+
        assert_eq!(patch.target(), target);
+
        assert_eq!(patch.version(), 0);
+

+
        let (_, revision) = patch.latest().unwrap();
+

+
        assert_eq!(revision.author.id(), &author);
+
        assert_eq!(revision.description(), None);
+
        assert_eq!(revision.discussion.len(), 0);
+
        assert_eq!(revision.oid, oid);
+
        assert_eq!(revision.base, base);
+
    }
+

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

+
        let id = patch.id;
+
        let (rid, _) = patch.revisions().next().unwrap();
+
        let _merge = patch.merge(*rid, base, &signer).unwrap();
+

+
        let patch = patches.get(&id).unwrap().unwrap();
+

+
        let (_, r) = patch.revisions().next().unwrap();
+
        let merges = r.merges.iter().collect::<Vec<_>>();
+
        assert_eq!(merges.len(), 1);
+

+
        let merge = merges.first().unwrap();
+
        assert_eq!(merge.node, *signer.public_key());
+
        assert_eq!(merge.commit, base);
+
    }
+

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

+
        let (rid, _) = patch.latest().unwrap();
+
        patch
+
            .review(
+
                *rid,
+
                Some(Verdict::Accept),
+
                Some("LGTM".to_owned()),
+
                vec![],
+
                &signer,
+
            )
+
            .unwrap();
+

+
        let id = patch.id;
+
        let patch = patches.get(&id).unwrap().unwrap();
+
        let (_, revision) = patch.latest().unwrap();
+
        assert_eq!(revision.reviews.iter().count(), 1);
+

+
        let review = revision.reviews.get(signer.public_key()).unwrap();
+
        assert_eq!(review.verdict(), Some(Verdict::Accept));
+
        assert_eq!(review.comment(), Some("LGTM"));
+
    }
+

+
    #[test]
+
    fn test_patch_update() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let (_, signer, project) = test::setup::context(&tmp);
+
        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 mut patches = Patches::open(*signer.public_key(), &project).unwrap();
+
        let mut patch = patches
+
            .create(
+
                "My first patch",
+
                "Blah blah blah.",
+
                MergeTarget::Delegates,
+
                base,
+
                rev0_oid,
+
                &[],
+
                &signer,
+
            )
+
            .unwrap();
+

+
        assert_eq!(patch.description(), Some("Blah blah blah."));
+
        assert_eq!(patch.version(), 0);
+

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

+
        let id = patch.id;
+
        let patch = patches.get(&id).unwrap().unwrap();
+
        assert_eq!(patch.version(), 1);
+
        assert_eq!(patch.revisions.len(), 2);
+

+
        let (_, revision) = patch.latest().unwrap();
+

+
        assert_eq!(patch.version(), 1);
+
        assert_eq!(revision.oid, rev1_oid);
+
        assert_eq!(revision.description(), Some("I've made changes."));
+
    }
}
added radicle/src/cob/tag.rs
@@ -0,0 +1,174 @@
+
#![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::automerge::doc::Document;
+
use crate::cob::automerge::shared::FromHistory;
+
use crate::cob::automerge::store::{Error, Store};
+
use crate::cob::automerge::transaction::TransactionError;
+
use crate::cob::common::*;
+
use crate::cob::{Contents, History, ObjectId, Timestamp, 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| {
+
            let bytes = entry.contents();
+
            match automerge::Change::from_bytes(bytes.clone()) {
+
                Ok(change) => {
+
                    doc.apply_changes([change]).ok();
+
                }
+
                Err(_err) => {
+
                    // Ignore
+
                }
+
            }
+
            ControlFlow::Continue(doc)
+
        });
+
        let 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(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");
+
    }
+
}