radicle-heartwood-lfs/radicle/src/cob/automerge/patch.rs

836 lines
26 KiB
Rust

#![allow(clippy::too_many_arguments)]
use std::collections::{HashMap, HashSet};
use std::convert::TryFrom;
use std::ops::{ControlFlow, Deref};
use std::sync::Arc;
use automerge::transaction::Transactable;
use automerge::{Automerge, AutomergeError, ObjType, ScalarValue, Value};
use nonempty::NonEmpty;
use crate::cob::automerge::doc::{Document, DocumentError};
use crate::cob::automerge::shared;
use crate::cob::automerge::shared::*;
use crate::cob::automerge::store::{Error, Store};
use crate::cob::automerge::transaction::{Transaction, TransactionError};
use crate::cob::automerge::value::{FromValue, ValueError};
use crate::cob::common::*;
use crate::cob::{Contents, History, ObjectId, Timestamp, TypeName};
use crate::git;
use crate::prelude::*;
// Re-export generic patch.
pub use crate::cob::patch::*;
impl TryFrom<Document<'_>> for Patch {
type Error = DocumentError;
fn try_from(doc: Document) -> Result<Self, Self::Error> {
let obj_id = doc.get_id(automerge::ObjId::Root, "patch")?;
let title = doc.get(&obj_id, "title")?;
let author = doc.get(&obj_id, "author")?;
let state = doc.get(&obj_id, "state")?;
let target = doc.get(&obj_id, "target")?;
let timestamp = doc.get(&obj_id, "timestamp")?;
let revisions = doc.list(&obj_id, "revisions", lookup::revision)?;
let labels: HashSet<Label> = doc.keys(&obj_id, "labels")?;
let revisions =
NonEmpty::from_vec(revisions).ok_or(DocumentError::EmptyList("revisions"))?;
let author: Author = Author::new(author);
Ok(Self {
author,
title,
state,
target,
labels,
revisions,
timestamp,
})
}
}
pub struct PatchStore<'a> {
store: Store<'a, Patch>,
}
impl<'a> Deref for PatchStore<'a> {
type Target = Store<'a, Patch>;
fn deref(&self) -> &Self::Target {
&self.store
}
}
impl<'a> PatchStore<'a> {
/// Create a new patch store.
pub fn new(store: Store<'a, Patch>) -> Self {
Self { store }
}
/// Get a patch by id.
pub fn get(&self, id: &ObjectId) -> Result<Option<Patch>, Error> {
self.store.get(id)
}
/// Create a patch.
pub fn create<G: Signer>(
&self,
title: &str,
description: &str,
target: MergeTarget,
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
labels: &[Label],
signer: &G,
) -> Result<PatchId, Error> {
let author = self.author();
let timestamp = Timestamp::now();
let revision = Revision::new(
author.clone(),
base.into(),
oid.into(),
description.to_owned(),
timestamp,
);
let contents = events::create(&author, title, &revision, target, timestamp, labels)?;
let cob = self.store.create("Create patch", contents, signer)?;
Ok(*cob.id())
}
/// Comment on a patch.
pub fn comment<G: Signer>(
&self,
patch_id: &PatchId,
revision_ix: RevisionIx,
body: &str,
signer: &G,
) -> Result<(), Error> {
let author = self.author();
let mut patch = self.store.get_raw(patch_id)?;
let timestamp = Timestamp::now();
let changes = events::comment(&mut patch, revision_ix, &author, body, timestamp)?;
self.store
.update(*patch_id, "Add comment", changes, signer)?;
Ok(())
}
/// Update a patch with new code. Creates a new revision.
pub fn update<G: Signer>(
&self,
patch_id: &PatchId,
comment: impl ToString,
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
signer: &G,
) -> Result<RevisionIx, Error> {
let author = self.author();
let timestamp = Timestamp::now();
let revision = Revision::new(
author,
base.into(),
oid.into(),
comment.to_string(),
timestamp,
);
let mut patch = self.get_raw(patch_id)?;
let (revision_ix, changes) = events::update(&mut patch, revision)?;
self.store
.update(*patch_id, "Update patch", changes, signer)?;
Ok(revision_ix)
}
/// Reply to a patch comment.
pub fn reply<G: Signer>(
&self,
patch_id: &PatchId,
revision_ix: RevisionIx,
comment_id: CommentId,
reply: &str,
signer: &G,
) -> Result<(), Error> {
let author = self.author();
let mut patch = self.get_raw(patch_id)?;
let changes = events::reply(
&mut patch,
revision_ix,
comment_id,
&author,
reply,
Timestamp::now(),
)?;
self.store.update(*patch_id, "Reply", changes, signer)?;
Ok(())
}
/// Review a patch revision.
pub fn review<G: Signer>(
&self,
patch_id: &PatchId,
revision_ix: RevisionIx,
verdict: Option<Verdict>,
comment: impl Into<String>,
inline: Vec<CodeComment>,
signer: &G,
) -> Result<(), Error> {
let timestamp = Timestamp::now();
let review = Review::new(self.author(), verdict, comment, inline, timestamp);
let mut patch = self.get_raw(patch_id)?;
let (_, changes) = events::review(&mut patch, revision_ix, review)?;
self.store
.update(*patch_id, "Review patch", changes, signer)?;
Ok(())
}
/// Merge a patch revision.
pub fn merge<G: Signer>(
&self,
patch_id: &PatchId,
revision_ix: RevisionIx,
commit: git::Oid,
signer: &G,
) -> Result<Merge, Error> {
let timestamp = Timestamp::now();
let merge = Merge {
node: *signer.public_key(),
commit,
timestamp,
};
let mut patch = self.get_raw(patch_id)?;
let changes = events::merge(&mut patch, revision_ix, &merge)?;
self.store
.update(*patch_id, "Merge revision", changes, signer)?;
Ok(merge)
}
/// Get the patch count.
pub fn count(&self) -> Result<usize, Error> {
let cobs = self.store.list()?;
Ok(cobs.len())
}
/// Get all patches for this project.
pub fn all(&self) -> Result<Vec<(PatchId, Patch)>, Error> {
let mut patches = self.store.list()?;
patches.sort_by_key(|(_, p)| p.timestamp);
Ok(patches)
}
/// Get proposed patches.
pub fn proposed(&self) -> Result<impl Iterator<Item = (PatchId, Patch)>, Error> {
let all = self.all()?;
Ok(all.into_iter().filter(|(_, p)| p.is_proposed()))
}
/// Get patches proposed by the given key.
pub fn proposed_by<'b>(
&'b self,
who: &'b PublicKey,
) -> Result<impl Iterator<Item = (PatchId, Patch)> + '_, Error> {
Ok(self.proposed()?.filter(move |(_, p)| p.author.id() == who))
}
}
impl From<State> for ScalarValue {
fn from(state: State) -> Self {
match state {
State::Proposed => ScalarValue::from("proposed"),
State::Draft => ScalarValue::from("draft"),
State::Archived => ScalarValue::from("archived"),
}
}
}
impl<'a> FromValue<'a> for State {
fn from_value(value: Value<'a>) -> Result<Self, ValueError> {
let state = value.to_str().ok_or(ValueError::InvalidType)?;
match state {
"proposed" => Ok(Self::Proposed),
"draft" => Ok(Self::Draft),
"archived" => Ok(Self::Archived),
_ => Err(ValueError::InvalidValue(value.to_string())),
}
}
}
impl Revision {
/// Put this object into an automerge document.
fn put<'a>(
&self,
mut tx: impl AsMut<automerge::transaction::Transaction<'a>>,
id: &automerge::ObjId,
) -> Result<(), AutomergeError> {
assert!(
self.merges.is_empty(),
"Cannot put revision with non-empty merges"
);
assert!(
self.reviews.is_empty(),
"Cannot put revision with non-empty reviews"
);
assert!(
self.discussion.is_empty(),
"Cannot put revision with non-empty discussion"
);
let tx = tx.as_mut();
tx.put(id, "id", self.id.to_string())?;
tx.put(id, "oid", self.oid.to_string())?;
tx.put(id, "base", self.base.to_string())?;
self.comment.put(tx, id)?;
tx.put_object(id, "discussion", ObjType::List)?;
tx.put_object(id, "reviews", ObjType::Map)?;
tx.put_object(id, "merges", ObjType::List)?;
tx.put(id, "timestamp", self.timestamp)?;
Ok(())
}
}
impl From<Verdict> for ScalarValue {
fn from(verdict: Verdict) -> Self {
#[allow(clippy::unwrap_used)]
let s = serde_json::to_string(&verdict).unwrap(); // Cannot fail.
ScalarValue::from(s)
}
}
impl<'a> FromValue<'a> for Verdict {
fn from_value(value: Value) -> Result<Self, ValueError> {
let verdict = value.to_str().ok_or(ValueError::InvalidType)?;
serde_json::from_str(verdict).map_err(|e| ValueError::Other(Arc::new(e)))
}
}
impl Review {
/// Put this object into an automerge document.
fn put<'a>(
&self,
mut tx: impl AsMut<automerge::transaction::Transaction<'a>>,
id: &automerge::ObjId,
) -> Result<(), AutomergeError> {
assert!(
self.inline.is_empty(),
"Cannot put review with non-empty inline comments"
);
let tx = tx.as_mut();
tx.put(id, "author", &self.author)?;
tx.put(
id,
"verdict",
if let Some(v) = self.verdict {
v.into()
} else {
ScalarValue::Null
},
)?;
self.comment.put(tx, id)?;
tx.put_object(id, "inline", ObjType::List)?;
tx.put(id, "timestamp", self.timestamp)?;
Ok(())
}
}
impl FromHistory for Patch {
fn type_name() -> &'static TypeName {
&TYPENAME
}
/// Create a patch from an automerge history.
fn from_history(history: &History) -> Result<Self, Error> {
let doc = history.traverse(Automerge::new(), |mut doc, entry| {
let bytes = entry.contents();
match automerge::Change::from_bytes(bytes.clone()) {
Ok(change) => {
doc.apply_changes([change]).ok();
}
Err(_err) => {
// Ignore
}
}
ControlFlow::Continue(doc)
});
let patch = Patch::try_from(Document::new(&doc))?;
Ok(patch)
}
}
mod lookup {
use super::*;
pub fn revision(
doc: Document,
revision_id: &automerge::ObjId,
) -> Result<Revision, DocumentError> {
let comment_id = doc.get_id(revision_id, "comment")?;
let reviews_id = doc.get_id(revision_id, "reviews")?;
let id = doc.get(revision_id, "id")?;
let base = doc.get(revision_id, "base")?;
let oid = doc.get(revision_id, "oid")?;
let timestamp = doc.get(revision_id, "timestamp")?;
let merges: Vec<Merge> = doc.list(revision_id, "merges", self::merge)?;
// Discussion.
let comment = shared::lookup::comment(doc, &comment_id)?;
let discussion: Discussion = doc.list(revision_id, "discussion", shared::lookup::thread)?;
// Reviews.
let mut reviews: HashMap<NodeId, Review> = HashMap::new();
for key in (*doc).keys(&reviews_id) {
let review_id = doc.get_id(&reviews_id, key)?;
let review = self::review(doc, &review_id)?;
reviews.insert(*review.author.id(), review);
}
Ok(Revision {
id,
base,
oid,
comment,
discussion,
reviews,
merges,
changeset: (),
timestamp,
})
}
pub fn merge(doc: Document, obj_id: &automerge::ObjId) -> Result<Merge, DocumentError> {
let node = doc.get(obj_id, "peer")?;
let commit = doc.get(obj_id, "commit")?;
let timestamp = doc.get(obj_id, "timestamp")?;
Ok(Merge {
node,
commit,
timestamp,
})
}
pub fn review(doc: Document, obj_id: &automerge::ObjId) -> Result<Review, DocumentError> {
let author = doc.get(obj_id, "author")?;
let verdict = doc.get(obj_id, "verdict")?;
let timestamp = doc.get(obj_id, "timestamp")?;
let comment = doc.lookup(obj_id, "comment", shared::lookup::thread)?;
let inline = vec![];
Ok(Review {
author: Author::new(author),
comment,
verdict,
inline,
timestamp,
})
}
}
/// Patch events.
mod events {
use super::*;
use automerge::{
transaction::{CommitOptions, Transactable},
ObjId,
};
pub fn create(
author: &Author,
title: &str,
revision: &Revision,
target: MergeTarget,
timestamp: Timestamp,
labels: &[Label],
) -> Result<Contents, TransactionError> {
let title = title.trim();
if title.is_empty() {
return Err(TransactionError::InvalidValue("title"));
}
let mut doc = Automerge::new();
let _patch = doc
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("Create patch".to_owned()),
|tx| {
let mut tx = Transaction::new(tx);
let patch_id = tx.put_object(ObjId::Root, "patch", ObjType::Map)?;
tx.put(&patch_id, "title", title)?;
tx.put(&patch_id, "author", author)?;
tx.put(&patch_id, "state", State::Proposed)?;
tx.put(&patch_id, "target", target)?;
tx.put(&patch_id, "timestamp", timestamp)?;
let labels_id = tx.put_object(&patch_id, "labels", ObjType::Map)?;
for label in labels {
tx.put(&labels_id, label.name().trim(), true)?;
}
let revisions_id = tx.put_object(&patch_id, "revisions", ObjType::List)?;
let revision_id = tx.insert_object(&revisions_id, 0, ObjType::Map)?;
revision.put(tx, &revision_id)?;
Ok(patch_id)
},
)
.map_err(|failure| failure.error)?
.result;
Ok(doc.save_incremental())
}
pub fn comment(
patch: &mut Automerge,
revision_ix: RevisionIx,
author: &Author,
body: &str,
timestamp: Timestamp,
) -> Result<Contents, TransactionError> {
let _comment = patch
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("Add comment".to_owned()),
|t| {
let mut tx = Transaction::new(t);
let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
let (_, discussion_id) = tx.get(&revision_id, "discussion")?;
let length = tx.length(&discussion_id);
let comment = tx.insert_object(&discussion_id, length, ObjType::Map)?;
tx.put(&comment, "author", author)?;
tx.put(&comment, "body", body.trim())?;
tx.put(&comment, "timestamp", timestamp)?;
tx.put_object(&comment, "replies", ObjType::List)?;
tx.put_object(&comment, "reactions", ObjType::Map)?;
Ok(comment)
},
)
.map_err(|failure| failure.error)?
.result;
#[allow(clippy::unwrap_used)]
let change = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
Ok(change)
}
pub fn update(
patch: &mut Automerge,
revision: Revision,
) -> Result<(RevisionIx, Contents), TransactionError> {
let revision_ix = patch
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("Merge revision".to_owned()),
|tx| {
let mut tx = Transaction::new(tx);
let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
let ix = tx.length(&revisions_id);
let revision_id = tx.insert_object(&revisions_id, ix, ObjType::Map)?;
revision.put(tx, &revision_id)?;
Ok(ix)
},
)
.map_err(|failure| failure.error)?
.result;
#[allow(clippy::unwrap_used)]
let change = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
Ok((revision_ix, change))
}
pub fn reply(
patch: &mut Automerge,
revision_ix: RevisionIx,
comment_id: CommentId,
author: &Author,
body: &str,
timestamp: Timestamp,
) -> Result<Contents, TransactionError> {
patch
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("Reply".to_owned()),
|tx| {
let mut tx = Transaction::new(tx);
let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
let (_, discussion_id) = tx.get(&revision_id, "discussion")?;
let (_, comment_id) = tx.get(&discussion_id, usize::from(comment_id))?;
let (_, replies_id) = tx.get(&comment_id, "replies")?;
let length = tx.length(&replies_id);
let reply = tx.insert_object(&replies_id, length, ObjType::Map)?;
// Nb. Replies don't themselves have replies.
tx.put(&reply, "author", author)?;
tx.put(&reply, "body", body.trim())?;
tx.put(&reply, "timestamp", timestamp)?;
tx.put_object(&reply, "reactions", ObjType::Map)?;
Ok(())
},
)
.map_err(|failure| failure.error)?;
#[allow(clippy::unwrap_used)]
let change = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
Ok(change)
}
pub fn review(
patch: &mut Automerge,
revision_ix: RevisionIx,
review: Review,
) -> Result<((), Contents), TransactionError> {
patch
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("Review patch".to_owned()),
|tx| {
let mut tx = Transaction::new(tx);
let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
let (_, reviews_id) = tx.get(&revision_id, "reviews")?;
let review_id =
tx.put_object(&reviews_id, review.author.id.to_human(), ObjType::Map)?;
review.put(tx, &review_id)?;
Ok(())
},
)
.map_err(|failure| failure.error)?;
#[allow(clippy::unwrap_used)]
let change = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
Ok(((), change))
}
pub fn merge(
patch: &mut Automerge,
revision_ix: RevisionIx,
merge: &Merge,
) -> Result<Contents, TransactionError> {
patch
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("Merge revision".to_owned()),
|tx| {
let mut tx = Transaction::new(tx);
let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
let (_, merges_id) = tx.get(&revision_id, "merges")?;
let length = tx.length(&merges_id);
let merge_id = tx.insert_object(&merges_id, length, ObjType::Map)?;
tx.put(&merge_id, "peer", merge.node.to_string())?;
tx.put(&merge_id, "commit", merge.commit.to_string())?;
tx.put(&merge_id, "timestamp", merge.timestamp)?;
Ok(())
},
)
.map_err(|failure| failure.error)?;
#[allow(clippy::unwrap_used)]
let change = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
Ok(change)
}
}
#[cfg(test)]
mod test {
use std::str::FromStr;
use super::*;
use crate::test;
#[test]
fn test_patch_create_and_get() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let patches = store.patches();
let author = *signer.public_key();
let timestamp = Timestamp::now();
let target = MergeTarget::Delegates;
let oid = git::Oid::from(git2::Oid::zero());
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
let patch_id = patches
.create(
"My first patch",
"Blah blah blah.",
target,
base,
oid,
&[],
&signer,
)
.unwrap();
let patch = patches.get(&patch_id).unwrap().unwrap();
assert_eq!(&patch.title, "My first patch");
assert_eq!(patch.author.id(), &author);
assert_eq!(patch.state, State::Proposed);
assert!(patch.timestamp >= timestamp);
let revision = patch.revisions.head;
assert_eq!(revision.author(), &store.author());
assert_eq!(revision.comment.body, "Blah blah blah.");
assert_eq!(revision.discussion.len(), 0);
assert_eq!(revision.oid, oid);
assert_eq!(revision.base, base);
assert!(revision.reviews.is_empty());
assert!(revision.merges.is_empty());
}
#[test]
fn test_patch_merge() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let patches = store.patches();
let target = MergeTarget::Delegates;
let oid = git::Oid::from(git2::Oid::zero());
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
let patch_id = patches
.create(
"My first patch",
"Blah blah blah.",
target,
base,
oid,
&[],
&signer,
)
.unwrap();
let _merge = patches.merge(&patch_id, 0, base, &signer).unwrap();
let patch = patches.get(&patch_id).unwrap().unwrap();
let merges = patch.revisions.head.merges;
assert_eq!(merges.len(), 1);
assert_eq!(merges[0].node, *signer.public_key());
assert_eq!(merges[0].commit, base);
}
#[test]
fn test_patch_review() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let patches = store.patches();
let whoami = store.author();
let target = MergeTarget::Delegates;
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
let rev_oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
let patch_id = patches
.create(
"My first patch",
"Blah blah blah.",
target,
base,
rev_oid,
&[],
&signer,
)
.unwrap();
patches
.review(&patch_id, 0, Some(Verdict::Accept), "LGTM", vec![], &signer)
.unwrap();
let patch = patches.get(&patch_id).unwrap().unwrap();
let reviews = patch.revisions.head.reviews;
assert_eq!(reviews.len(), 1);
let review = reviews.get(whoami.id()).unwrap();
assert_eq!(review.author.id(), whoami.id());
assert_eq!(review.verdict, Some(Verdict::Accept));
assert_eq!(review.comment.body.as_str(), "LGTM");
}
#[test]
fn test_patch_update() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let patches = store.patches();
let target = MergeTarget::Delegates;
let base = git::Oid::from_str("af08e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
let rev0_oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
let rev1_oid = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
let patch_id = patches
.create(
"My first patch",
"Blah blah blah.",
target,
base,
rev0_oid,
&[],
&signer,
)
.unwrap();
let patch = patches.get(&patch_id).unwrap().unwrap();
assert_eq!(patch.description(), "Blah blah blah.");
assert_eq!(patch.version(), 0);
let revision_id = patches
.update(&patch_id, "I've made changes.", base, rev1_oid, &signer)
.unwrap();
assert_eq!(revision_id, 1);
let patch = patches.get(&patch_id).unwrap().unwrap();
assert_eq!(patch.description(), "I've made changes.");
assert_eq!(patch.revisions.len(), 2);
assert_eq!(patch.version(), 1);
let (id, revision) = patch.latest();
assert_eq!(id, 1);
assert_eq!(revision.oid, rev1_oid);
assert_eq!(revision.description(), "I've made changes.");
}
}