diff --git a/Cargo.lock b/Cargo.lock index cfdeaeb3..72d15614 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,26 +118,6 @@ version = "1.1.0" 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" @@ -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", @@ -1017,15 +997,6 @@ dependencies = [ "pin-utils", ] -[[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" @@ -1558,12 +1529,6 @@ dependencies = [ "spin", ] -[[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" @@ -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", ] @@ -2908,15 +2871,6 @@ version = "1.10.0" 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" @@ -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", @@ -3529,27 +3483,6 @@ dependencies = [ "percent-encoding", ] -[[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" diff --git a/radicle-cli/src/commands/inspect.rs b/radicle-cli/src/commands/inspect.rs index 796aaea3..307c823a 100644 --- a/radicle-cli/src/commands/inspect.rs +++ b/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::::from( diff --git a/radicle-cli/src/commands/merge.rs b/radicle-cli/src/commands/merge.rs index caa01e78..6315a33e 100644 --- a/radicle-cli/src/commands/merge.rs +++ b/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())?; diff --git a/radicle-cli/src/commands/patch/common.rs b/radicle-cli/src/commands/patch/common.rs index 48178096..ad184b78 100644 --- a/radicle-cli/src/commands/patch/common.rs +++ b/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> { +) -> anyhow::Result> { // 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) diff --git a/radicle-cli/src/commands/patch/create.rs b/radicle-cli/src/commands/patch/create.rs index eefe4d8f..a4c15e34 100644 --- a/radicle-cli/src/commands/patch/create.rs +++ b/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( - 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( 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( 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)); diff --git a/radicle-cli/src/commands/patch/list.rs b/radicle-cli/src/commands/patch/list.rs index 3e321653..d004ce07 100644 --- a/radicle-cli/src/commands/patch/list.rs +++ b/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, 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(" "), ), )); diff --git a/radicle-cli/src/commands/review.rs b/radicle-cli/src/commands/review.rs index 5ab59202..ff41fd72 100644 --- a/radicle-cli/src/commands/review.rs +++ b/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, )?; diff --git a/radicle/Cargo.toml b/radicle/Cargo.toml index 7f8bb1d5..71c9fa3a 100644 --- a/radicle/Cargo.toml +++ b/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] diff --git a/radicle/src/cob.rs b/radicle/src/cob.rs index 96683bbc..6a2f71c7 100644 --- a/radicle/src/cob.rs +++ b/radicle/src/cob.rs @@ -1,4 +1,3 @@ -pub mod automerge; pub mod common; pub mod issue; pub mod patch; diff --git a/radicle/src/cob/automerge.rs b/radicle/src/cob/automerge.rs deleted file mode 100644 index ac384fd0..00000000 --- a/radicle/src/cob/automerge.rs +++ /dev/null @@ -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; diff --git a/radicle/src/cob/automerge/doc.rs b/radicle/src/cob/automerge/doc.rs deleted file mode 100644 index a8b1524d..00000000 --- a/radicle/src/cob/automerge/doc.rs +++ /dev/null @@ -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, P: Into, T: FromValue<'a>>( - &self, - id: O, - prop: P, - ) -> Result { - 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, P: Into>( - &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, P: Into>( - &self, - id: O, - prop: P, - ) -> Result { - self.get_raw(id, prop).map(|(_, id)| id) - } - - /// Get a property using a lookup function. - pub fn lookup, P: Into>( - &self, - id: O, - prop: P, - lookup: fn(Document, &automerge::ObjId) -> Result, - ) -> Result { - let obj_id = self.get_id(&id, prop)?; - lookup(*self, &obj_id) - } - - /// Get a list-like value from an object. - pub fn list, P: Into>( - &self, - id: O, - prop: P, - item: fn(Document, &automerge::ObjId) -> Result, - ) -> Result, 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 = 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, - P: Into, - >( - &self, - id: O, - prop: P, - mut value: impl FnMut(&mut V), - ) -> Result, 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, - P: Into, - >( - &self, - id: O, - prop: P, - mut f: impl FnMut(&mut T, V), - ) -> Result { - 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, P: Into>( - &self, - id: O, - prop: P, - ) -> Result, 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 - } -} diff --git a/radicle/src/cob/automerge/patch.rs b/radicle/src/cob/automerge/patch.rs deleted file mode 100644 index 333d2d84..00000000 --- a/radicle/src/cob/automerge/patch.rs +++ /dev/null @@ -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> for Patch { - type Error = DocumentError; - - fn try_from(doc: Document) -> Result { - 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 = 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, Error> { - self.store.get(id) - } - - /// Create a patch. - pub fn create( - &self, - title: &str, - description: &str, - target: MergeTarget, - base: impl Into, - oid: impl Into, - labels: &[Tag], - signer: &G, - ) -> Result { - 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( - &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( - &self, - patch_id: &PatchId, - comment: impl ToString, - base: impl Into, - oid: impl Into, - signer: &G, - ) -> Result { - 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( - &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( - &self, - patch_id: &PatchId, - revision_ix: RevisionIx, - verdict: Option, - comment: impl Into, - inline: Vec, - 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( - &self, - patch_id: &PatchId, - revision_ix: RevisionIx, - commit: git::Oid, - signer: &G, - ) -> Result { - 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 { - let cobs = self.store.list()?; - - Ok(cobs.len()) - } - - /// Get all patches for this project. - pub fn all(&self) -> Result, Error> { - let mut patches = self.store.list()?; - patches.sort_by_key(|(_, p)| p.timestamp); - - Ok(patches) - } - - /// Get proposed patches. - pub fn proposed(&self) -> Result, 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 + '_, Error> { - Ok(self.proposed()?.filter(move |(_, p)| p.author.id() == who)) - } -} - -impl From 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 { - 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>, - 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 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 { - 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>, - 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 { - 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 { - 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 = 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 = 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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."); - } -} diff --git a/radicle/src/cob/automerge/shared.rs b/radicle/src/cob/automerge/shared.rs deleted file mode 100644 index e1e4aefb..00000000 --- a/radicle/src/cob/automerge/shared.rs +++ /dev/null @@ -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; -} - -impl<'a> FromValue<'a> for Color { - fn from_value(val: Value<'a>) -> Result { - 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(&self, serializer: S) -> Result - where - S: serde::ser::Serializer, - { - let s = self.to_string(); - serializer.serialize_str(&s) - } -} - -impl<'a> Deserialize<'a> for Color { - fn deserialize(deserializer: D) -> Result - 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 { - 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 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 { - 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, 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 = 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, 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(); - } -} diff --git a/radicle/src/cob/automerge/store.rs b/radicle/src/cob/automerge/store.rs deleted file mode 100644 index 99d388b5..00000000 --- a/radicle/src/cob/automerge/store.rs +++ /dev/null @@ -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, - raw: &'a storage::Repository, - witness: PhantomData, -} - -impl<'a, T> AsRef 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 { - 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( - &self, - object_id: ObjectId, - message: &'static str, - changes: Contents, - signer: &G, - ) -> Result { - 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( - &self, - message: &'static str, - contents: Contents, - signer: &G, - ) -> Result { - 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, 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 { - 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, 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() - } -} diff --git a/radicle/src/cob/automerge/transaction.rs b/radicle/src/cob/automerge/transaction.rs deleted file mode 100644 index 0101dc3c..00000000 --- a/radicle/src/cob/automerge/transaction.rs +++ /dev/null @@ -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> 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, P: Into>( - &self, - obj: O, - prop: P, - ) -> Result, TransactionError> { - let prop = prop.into(); - let result = self.raw.get(obj, prop)?; - - Ok(result) - } - - pub fn get, P: Into>( - &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), -} diff --git a/radicle/src/cob/automerge/value.rs b/radicle/src/cob/automerge/value.rs deleted file mode 100644 index 5372335f..00000000 --- a/radicle/src/cob/automerge/value.rs +++ /dev/null @@ -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; -} - -#[derive(thiserror::Error, Debug)] -pub enum ValueError { - #[error("invalid type")] - InvalidType, - #[error("invalid value: `{0}`")] - InvalidValue(String), - #[error("value error: {0}")] - Other(Arc), -} - -impl<'a, T> FromValue<'a> for Option -where - T: FromValue<'a>, -{ - fn from_value(val: Value<'a>) -> Result, 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 { - 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 { - 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 { - 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 { - 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 { - val.into_string().map_err(|_| ValueError::InvalidType) - } -} - -impl<'a> FromValue<'a> for MergeTarget { - fn from_value(value: Value<'a>) -> Result { - let state = value.to_str().ok_or(ValueError::InvalidType)?; - - match state { - "delegates" => Ok(Self::Delegates), - _ => Err(ValueError::InvalidValue(value.to_string())), - } - } -} - -impl From for ScalarValue { - fn from(target: MergeTarget) -> Self { - match target { - MergeTarget::Delegates => ScalarValue::from("delegates"), - } - } -} diff --git a/radicle/src/cob/common.rs b/radicle/src/cob/common.rs index 0883d41d..e89ab2d3 100644 --- a/radicle/src/cob/common.rs +++ b/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. -pub type Replies = Vec; - -/// 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(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let s = self.to_string(); + serializer.serialize_str(&s) } } -impl From for CommentId { - fn from(ix: usize) -> Self { - Self { ix } +impl<'a> Deserialize<'a> for Color { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'a>, + { + let color = String::deserialize(deserializer)?; + Self::from_str(&color).map_err(serde::de::Error::custom) } } -impl From for usize { - fn from(id: CommentId) -> Self { - id.ix - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Comment { - pub author: Author, - pub body: String, - pub reactions: HashMap, - pub replies: R, - pub timestamp: Timestamp, -} - -impl Comment { - pub fn new(author: Author, body: String, timestamp: Timestamp) -> Self { - Self { - author, - body, - reactions: HashMap::default(), - replies: R::default(), - 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(); } } diff --git a/radicle/src/cob/patch.rs b/radicle/src/cob/patch.rs index 70e6f4a4..77dac052 100644 --- a/radicle/src/cob/patch.rs +++ b/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 = Lazy::new(|| FromStr::from_str("xyz.radicle.patch").expect("type name is valid")); +pub type Change = crdt::Change; + /// 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, + remove: Vec, + }, + Revision { + base: git::Oid, + oid: git::Oid, + }, + Review { + revision: RevisionId, + comment: Option, + verdict: Option, + inline: Vec, + }, + Merge { + revision: RevisionId, + commit: git::Oid, + }, + Thread { + revision: RevisionId, + action: thread::Action, + }, +} + +impl Action { + pub fn decode(bytes: &[u8]) -> Result { + 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 -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>, + /// Patch description. + pub description: LWWReg>, + /// Current status of the patch. + pub status: LWWReg>, /// Target this patch is meant to be merged in. - pub target: MergeTarget, - /// Labels associated with the patch. - pub labels: HashSet, + pub target: LWWReg>, + /// Associated tags. + pub tags: LWWSet, /// List of patch revisions. The initial changeset is part of the /// first revision. - pub revisions: NonEmpty>, - /// Patch creation time. - pub timestamp: Timestamp, + pub revisions: BTreeMap>, +} + +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 { + 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, + waiting: &mut BTreeMap>, + ) -> Result<(), Error> { + let mut queue = changes.into_iter().collect::>(); + + 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>, + ) -> 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 { - /// 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, + pub discussion: Thread, /// Merges of this revision into other repositories. - pub merges: Vec, - /// Code changeset for this revision. - pub changeset: T, + pub merges: LWWSet>, + /// Reviews of this revision's changes (one per actor). + pub reviews: LWWMap, /// 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 author(&self) -> &Author { - &self.comment.author + pub fn description(&self) -> Option<&str> { + self.discussion.first() } } +#[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, - /// 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, +} + +impl PartialOrd for CodeLocation { + fn partial_cmp(&self, other: &Self) -> Option { + 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, + pub verdict: LWWReg>, /// Review general comment. - pub comment: Comment, + pub comment: LWWReg>>, /// Review inline code comments. - pub inline: Vec, + pub inline: LWWSet>, /// Review timestamp. - pub timestamp: Timestamp, + pub timestamp: Max, +} + +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, - comment: impl Into, + comment: Option, inline: Vec, timestamp: Timestamp, ) -> Self { - let comment = Comment::new(author.clone(), comment.into(), timestamp); - Self { - author, - verdict, - comment, - inline, - timestamp, + 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 { + 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 { + id, + clock, + patch, + store, + } + } + + /// Get the internal logical clock. + pub fn clock(&self) -> &clock::Lamport { + &self.clock + } + + /// Edit patch metadata. + pub fn edit( + &mut self, + title: String, + description: String, + target: MergeTarget, + signer: &G, + ) -> Result { + let action = Action::Edit { + title, + description, + target, + }; + self.apply("Edit", action, signer) + } + + /// Comment on a patch revision. + pub fn comment>( + &mut self, + revision: RevisionId, + body: S, + signer: &G, + ) -> Result { + 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( + &mut self, + revision: RevisionId, + verdict: Option, + comment: Option, + inline: Vec, + signer: &G, + ) -> Result { + let action = Action::Review { + revision, + comment, + verdict, + inline, + }; + self.apply("Review patch", action, signer) + } + + /// Merge a patch revision. + pub fn merge( + &mut self, + revision: RevisionId, + commit: git::Oid, + signer: &G, + ) -> Result { + let action = Action::Merge { revision, commit }; + self.apply("Merge revision", action, signer) + } + + /// Update a patch with a new revision. + pub fn update( + &mut self, + description: impl Into, + base: impl Into, + oid: impl Into, + signer: &G, + ) -> Result { + 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( + &mut self, + add: impl IntoIterator, + remove: impl IntoIterator, + signer: &G, + ) -> Result { + let add = add.into_iter().collect::>(); + let remove = remove.into_iter().collect::>(); + let action = Action::Tag { add, remove }; + + self.apply("Tag", action, signer) + } + + /// Apply a change to the patch. + pub fn apply( + &mut self, + msg: &'static str, + action: Action, + signer: &G, + ) -> Result { + 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 { + 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, + description: impl Into, + target: MergeTarget, + base: impl Into, + oid: impl Into, + tags: &[Tag], + signer: &G, + ) -> Result, 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, 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, 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, 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 + '_, 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 { + permutations: [Vec; N], + } + + impl std::fmt::Debug for Changes { + 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::>() + )?; + } + Ok(()) + } + } + + impl Arbitrary for Changes { + fn arbitrary(g: &mut quickcheck::Gen) -> Self { + type State = (clock::Lamport, Vec); + + 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::>() + .as_slice(), + ) + .unwrap() + }) + .take(16) + .collect::>(); + + 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; 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::>(); + 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.")); + } } diff --git a/radicle/src/cob/automerge/label.rs b/radicle/src/cob/tag.rs similarity index 100% rename from radicle/src/cob/automerge/label.rs rename to radicle/src/cob/tag.rs