cob: Split generic code from automerge-specific
This commit is contained in:
parent
5526eceea0
commit
01ad0fb31d
|
|
@ -7,9 +7,9 @@ use anyhow::{anyhow, Context as _};
|
|||
use crate::terminal as term;
|
||||
use crate::terminal::args::{Args, Error, Help};
|
||||
|
||||
use radicle::cob::automerge::store::Store;
|
||||
use radicle::cob::common::{Label, Reaction};
|
||||
use radicle::cob::issue::{CloseReason, IssueId, State};
|
||||
use radicle::cob::shared::{Label, Reaction};
|
||||
use radicle::cob::store::Store;
|
||||
use radicle::storage::WriteStorage;
|
||||
|
||||
pub const HELP: Help = Help {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use anyhow::{anyhow, Context};
|
|||
|
||||
use crate::terminal as term;
|
||||
use crate::terminal::args::{Args, Error, Help};
|
||||
use radicle::cob;
|
||||
use radicle::cob::automerge;
|
||||
use radicle::cob::patch::RevisionIx;
|
||||
use radicle::cob::patch::{Patch, PatchId};
|
||||
use radicle::git;
|
||||
|
|
@ -141,7 +141,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 = cob::Store::open(*profile.id(), &repository)?;
|
||||
let cobs = automerge::Store::open(*profile.id(), &repository)?;
|
||||
let patches = cobs.patches();
|
||||
|
||||
if repo.head_detached()? {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use radicle::cob::patch::{MergeTarget, Patch, PatchId, PatchStore};
|
||||
use radicle::cob::automerge::patch::{MergeTarget, Patch, PatchId, PatchStore};
|
||||
use radicle::git;
|
||||
use radicle::git::raw::Oid;
|
||||
use radicle::prelude::*;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ use std::path::Path;
|
|||
|
||||
use anyhow::{anyhow, Context};
|
||||
|
||||
use radicle::cob::patch::{MergeTarget, Patch, PatchId, PatchStore};
|
||||
use radicle::cob::automerge;
|
||||
use radicle::cob::automerge::patch::{MergeTarget, Patch, PatchId, PatchStore};
|
||||
use radicle::git;
|
||||
use radicle::git::raw::Oid;
|
||||
use radicle::prelude::*;
|
||||
|
|
@ -51,7 +52,7 @@ pub fn run(
|
|||
));
|
||||
|
||||
let signer = term::signer(profile)?;
|
||||
let cobs = radicle::cob::Store::open(profile.public_key, storage)?;
|
||||
let cobs = automerge::Store::open(profile.public_key, storage)?;
|
||||
let patches = cobs.patches();
|
||||
|
||||
// `HEAD`; This is what we are proposing as a patch.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use radicle::cob;
|
||||
use radicle::cob::automerge;
|
||||
use radicle::cob::patch::{Patch, PatchId, Verdict};
|
||||
use radicle::git;
|
||||
use radicle::prelude::*;
|
||||
|
|
@ -22,7 +22,7 @@ pub fn run(
|
|||
}
|
||||
|
||||
let me = *profile.id();
|
||||
let cobs = cob::Store::open(*profile.id(), storage)?;
|
||||
let cobs = automerge::Store::open(*profile.id(), storage)?;
|
||||
let patches = cobs.patches();
|
||||
let proposed = patches.proposed()?;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::str::FromStr;
|
|||
use anyhow::{anyhow, Context};
|
||||
|
||||
use radicle::cob;
|
||||
use radicle::cob::patch::{PatchId, RevisionIx, Verdict};
|
||||
use radicle::cob::automerge::patch::{PatchId, RevisionIx, Verdict};
|
||||
use radicle::prelude::*;
|
||||
use radicle::rad;
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ 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::Store::open(*profile.id(), &repository)?;
|
||||
let cobs = cob::automerge::Store::open(*profile.id(), &repository)?;
|
||||
let patches = cobs.patches();
|
||||
|
||||
let patch_id = options.id;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ use std::str::FromStr;
|
|||
|
||||
use dialoguer::{console::style, console::Style, theme::ColorfulTheme, Input, Password};
|
||||
|
||||
use radicle::cob::common::CommentId;
|
||||
use radicle::cob::issue::Issue;
|
||||
use radicle::cob::shared::CommentId;
|
||||
use radicle::crypto::ssh::keystore::Passphrase;
|
||||
use radicle::crypto::Signer;
|
||||
use radicle::profile::env::RAD_PASSPHRASE;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::ops::Deref;
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use radicle::cob::shared::Reaction;
|
||||
use radicle::cob::common::Reaction;
|
||||
use radicle::cob::Timestamp;
|
||||
use radicle::crypto::{PublicKey, Signature, Signer};
|
||||
use radicle::hash;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
pub mod doc;
|
||||
pub mod automerge;
|
||||
pub mod common;
|
||||
pub mod issue;
|
||||
pub mod label;
|
||||
pub mod patch;
|
||||
pub mod shared;
|
||||
pub mod store;
|
||||
pub mod transaction;
|
||||
pub mod value;
|
||||
|
||||
pub use cob::{
|
||||
identity, object::collaboration::error, CollaborativeObject, Contents, Create, Entry, History,
|
||||
HistoryType, ObjectId, TypeName, Update,
|
||||
};
|
||||
pub use shared::Timestamp;
|
||||
pub use store::Store;
|
||||
pub use common::*;
|
||||
|
||||
use radicle_cob as cob;
|
||||
use radicle_git_ext::Oid;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
pub mod doc;
|
||||
pub mod issue;
|
||||
pub mod label;
|
||||
pub mod patch;
|
||||
pub mod shared;
|
||||
pub mod store;
|
||||
pub mod transaction;
|
||||
pub mod value;
|
||||
|
||||
pub use store::Store;
|
||||
|
|
@ -6,7 +6,7 @@ use std::str::FromStr;
|
|||
|
||||
use automerge::{Automerge, AutomergeError, ObjType};
|
||||
|
||||
use crate::cob::value::{FromValue, ValueError};
|
||||
use crate::cob::automerge::value::{FromValue, ValueError};
|
||||
|
||||
/// Error decoding a document.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
|
|
@ -0,0 +1,682 @@
|
|||
#![allow(clippy::large_enum_variant)]
|
||||
use std::collections::HashSet;
|
||||
use std::convert::TryFrom;
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
use automerge::{Automerge, ObjType, ScalarValue, Value};
|
||||
|
||||
use crate::cob::automerge::doc::{Document, DocumentError};
|
||||
use crate::cob::automerge::shared;
|
||||
use crate::cob::automerge::shared::*;
|
||||
use crate::cob::automerge::store::{Error, Store};
|
||||
use crate::cob::automerge::transaction::{Transaction, TransactionError};
|
||||
use crate::cob::automerge::value::{FromValue, ValueError};
|
||||
use crate::cob::common::*;
|
||||
use crate::cob::issue::*;
|
||||
use crate::cob::{Contents, History, ObjectId, Timestamp, TypeName};
|
||||
use crate::prelude::*;
|
||||
|
||||
impl From<State> for ScalarValue {
|
||||
fn from(state: State) -> Self {
|
||||
match state {
|
||||
State::Open => ScalarValue::from("open"),
|
||||
State::Closed {
|
||||
reason: CloseReason::Solved,
|
||||
} => ScalarValue::from("solved"),
|
||||
State::Closed {
|
||||
reason: CloseReason::Other,
|
||||
} => ScalarValue::from("closed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FromValue<'a> for State {
|
||||
fn from_value(value: Value) -> Result<Self, ValueError> {
|
||||
let state = value.to_str().ok_or(ValueError::InvalidType)?;
|
||||
|
||||
match state {
|
||||
"open" => Ok(Self::Open),
|
||||
"closed" => Ok(Self::Closed {
|
||||
reason: CloseReason::Other,
|
||||
}),
|
||||
"solved" => Ok(Self::Closed {
|
||||
reason: CloseReason::Solved,
|
||||
}),
|
||||
_ => Err(ValueError::InvalidValue(value.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromHistory for Issue {
|
||||
fn type_name() -> &'static TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn from_history(history: &History) -> Result<Self, Error> {
|
||||
let doc = history.traverse(Automerge::new(), |mut doc, entry| {
|
||||
let bytes = entry.contents();
|
||||
match automerge::Change::from_bytes(bytes.clone()) {
|
||||
Ok(change) => {
|
||||
doc.apply_changes([change]).ok();
|
||||
}
|
||||
Err(_err) => {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
ControlFlow::Continue(doc)
|
||||
});
|
||||
let issue = Issue::try_from(doc)?;
|
||||
|
||||
Ok(issue)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&History> for Issue {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(history: &History) -> Result<Self, Self::Error> {
|
||||
Issue::from_history(history)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Automerge> for Issue {
|
||||
type Error = DocumentError;
|
||||
|
||||
fn try_from(doc: Automerge) -> Result<Self, Self::Error> {
|
||||
let doc = Document::new(&doc);
|
||||
let obj_id = doc.get_id(automerge::ObjId::Root, "issue")?;
|
||||
let title = doc.get(&obj_id, "title")?;
|
||||
let comment_id = doc.get_id(&obj_id, "comment")?;
|
||||
let author = doc.get(&obj_id, "author").map(Author::new)?;
|
||||
let state = doc.get(&obj_id, "state")?;
|
||||
let timestamp = doc.get(&obj_id, "timestamp")?;
|
||||
|
||||
let comment = shared::lookup::comment(doc, &comment_id)?;
|
||||
let discussion: Discussion = doc.list(&obj_id, "discussion", shared::lookup::thread)?;
|
||||
let labels: HashSet<Label> = doc.keys(&obj_id, "labels")?;
|
||||
|
||||
Ok(Self {
|
||||
title,
|
||||
state,
|
||||
author,
|
||||
comment,
|
||||
discussion,
|
||||
labels,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IssueStore<'a> {
|
||||
store: Store<'a, Issue>,
|
||||
}
|
||||
|
||||
impl<'a> IssueStore<'a> {
|
||||
/// Create a new issue store.
|
||||
pub fn new(store: Store<'a, Issue>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Get an issue by id.
|
||||
pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, Error> {
|
||||
self.store.get(id)
|
||||
}
|
||||
|
||||
/// Create an issue.
|
||||
pub fn create<G: Signer>(
|
||||
&self,
|
||||
title: &str,
|
||||
description: &str,
|
||||
labels: &[Label],
|
||||
signer: &G,
|
||||
) -> Result<IssueId, Error> {
|
||||
let author = self.store.author();
|
||||
let timestamp = Timestamp::now();
|
||||
let contents = events::create(&author, title, description, timestamp, labels)?;
|
||||
let cob = self.store.create("Create issue", contents, signer)?;
|
||||
|
||||
Ok(*cob.id())
|
||||
}
|
||||
|
||||
/// Remove an issue.
|
||||
pub fn remove<G: Signer>(&self, _issue_id: &IssueId, _signer: &G) -> Result<(), Error> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Comment on an issue.
|
||||
pub fn comment<G: Signer>(
|
||||
&self,
|
||||
issue_id: &IssueId,
|
||||
body: &str,
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.store.author();
|
||||
let mut issue = self.store.get_raw(issue_id)?;
|
||||
let timestamp = Timestamp::now();
|
||||
let changes = events::comment(&mut issue, &author, body, timestamp)?;
|
||||
|
||||
self.store
|
||||
.update(*issue_id, "Add comment", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Life-cycle an issue, eg. open or close it.
|
||||
pub fn lifecycle<G: Signer>(
|
||||
&self,
|
||||
issue_id: &IssueId,
|
||||
state: State,
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.store.author();
|
||||
let mut issue = self.store.get_raw(issue_id)?;
|
||||
let changes = events::lifecycle(&mut issue, &author, state)?;
|
||||
|
||||
self.store.update(*issue_id, "Lifecycle", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Label an issue.
|
||||
pub fn label<G: Signer>(
|
||||
&self,
|
||||
issue_id: &IssueId,
|
||||
labels: &[Label],
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.store.author();
|
||||
let mut issue = self.store.get_raw(issue_id)?;
|
||||
let changes = events::label(&mut issue, &author, labels)?;
|
||||
|
||||
self.store.update(*issue_id, "Add label", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// React to an issue comment.
|
||||
pub fn react<G: Signer>(
|
||||
&self,
|
||||
issue_id: &IssueId,
|
||||
comment_id: CommentId,
|
||||
reaction: Reaction,
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.store.author();
|
||||
let mut issue = self.store.get_raw(issue_id)?;
|
||||
let changes = events::react(&mut issue, comment_id, &author, &[reaction])?;
|
||||
|
||||
self.store.update(*issue_id, "React", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reply to an issue comment.
|
||||
pub fn reply<G: Signer>(
|
||||
&self,
|
||||
issue_id: &IssueId,
|
||||
comment_id: CommentId,
|
||||
reply: &str,
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.store.author();
|
||||
let mut issue = self.store.get_raw(issue_id)?;
|
||||
let changes = events::reply(&mut issue, comment_id, &author, reply, Timestamp::now())?;
|
||||
|
||||
self.store.update(*issue_id, "Reply", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all issues, sorted by time.
|
||||
pub fn all(&self) -> Result<Vec<(IssueId, Issue)>, Error> {
|
||||
let mut issues = self.store.list()?;
|
||||
issues.sort_by_key(|(_, i)| i.timestamp);
|
||||
|
||||
Ok(issues)
|
||||
}
|
||||
|
||||
/// Get the issue count.
|
||||
pub fn count(&self) -> Result<usize, Error> {
|
||||
let issues = self.store.list()?;
|
||||
|
||||
Ok(issues.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue events.
|
||||
mod events {
|
||||
use super::*;
|
||||
use automerge::{
|
||||
transaction::{CommitOptions, Transactable},
|
||||
ObjId,
|
||||
};
|
||||
|
||||
pub fn create(
|
||||
author: &Author,
|
||||
title: &str,
|
||||
description: &str,
|
||||
timestamp: Timestamp,
|
||||
labels: &[Label],
|
||||
) -> Result<Contents, TransactionError> {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(TransactionError::InvalidValue("title"));
|
||||
}
|
||||
|
||||
let mut doc = Automerge::new();
|
||||
let _issue = doc
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message("Create issue".to_owned()),
|
||||
|tx| {
|
||||
let issue = tx.put_object(ObjId::Root, "issue", ObjType::Map)?;
|
||||
|
||||
tx.put(&issue, "title", title)?;
|
||||
tx.put(&issue, "author", author)?;
|
||||
tx.put(&issue, "state", State::Open)?;
|
||||
tx.put(&issue, "timestamp", timestamp)?;
|
||||
tx.put_object(&issue, "discussion", ObjType::List)?;
|
||||
|
||||
let labels_id = tx.put_object(&issue, "labels", ObjType::Map)?;
|
||||
for label in labels {
|
||||
tx.put(&labels_id, label.name().trim(), true)?;
|
||||
}
|
||||
|
||||
// Nb. The top-level comment doesn't have a `replies` field.
|
||||
let comment_id = tx.put_object(&issue, "comment", ObjType::Map)?;
|
||||
|
||||
tx.put(&comment_id, "body", description.trim())?;
|
||||
tx.put(&comment_id, "author", author)?;
|
||||
tx.put(&comment_id, "timestamp", timestamp)?;
|
||||
tx.put_object(&comment_id, "reactions", ObjType::Map)?;
|
||||
|
||||
Ok(issue)
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?
|
||||
.result;
|
||||
|
||||
Ok(doc.save_incremental())
|
||||
}
|
||||
|
||||
pub fn comment(
|
||||
issue: &mut Automerge,
|
||||
author: &Author,
|
||||
body: &str,
|
||||
timestamp: Timestamp,
|
||||
) -> Result<Contents, TransactionError> {
|
||||
let _comment = issue
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message("Add comment".to_owned()),
|
||||
|tx| {
|
||||
let mut tx = Transaction::new(tx);
|
||||
let (_obj, obj_id) = tx.get(ObjId::Root, "issue")?;
|
||||
let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
|
||||
|
||||
let length = tx.length(&discussion_id);
|
||||
let comment = tx.insert_object(&discussion_id, length, ObjType::Map)?;
|
||||
|
||||
tx.put(&comment, "author", author)?;
|
||||
tx.put(&comment, "body", body.trim())?;
|
||||
tx.put(&comment, "timestamp", timestamp)?;
|
||||
tx.put_object(&comment, "replies", ObjType::List)?;
|
||||
tx.put_object(&comment, "reactions", ObjType::Map)?;
|
||||
|
||||
Ok(comment)
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?
|
||||
.result;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
|
||||
|
||||
Ok(change)
|
||||
}
|
||||
|
||||
pub fn lifecycle(
|
||||
issue: &mut Automerge,
|
||||
author: &Author,
|
||||
state: State,
|
||||
) -> Result<Contents, TransactionError> {
|
||||
issue
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message(state.lifecycle_message()),
|
||||
|tx| {
|
||||
let mut tx = Transaction::new(tx);
|
||||
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
|
||||
tx.put(&obj_id, "state", state)?;
|
||||
tx.put(&obj_id, "author", author)?;
|
||||
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
|
||||
|
||||
Ok(change)
|
||||
}
|
||||
|
||||
pub fn label(
|
||||
issue: &mut Automerge,
|
||||
_author: &Author,
|
||||
labels: &[Label],
|
||||
) -> Result<Contents, TransactionError> {
|
||||
issue
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message("Label issue".to_owned()),
|
||||
|tx| {
|
||||
let mut tx = Transaction::new(tx);
|
||||
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
|
||||
let (_, labels_id) = tx.get(&obj_id, "labels")?;
|
||||
|
||||
for label in labels {
|
||||
tx.put(&labels_id, label.name().trim(), true)?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
|
||||
|
||||
Ok(change)
|
||||
}
|
||||
|
||||
pub fn reply(
|
||||
issue: &mut Automerge,
|
||||
comment_id: CommentId,
|
||||
author: &Author,
|
||||
body: &str,
|
||||
timestamp: Timestamp,
|
||||
) -> Result<Contents, TransactionError> {
|
||||
issue
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message("Reply".to_owned()),
|
||||
|tx| {
|
||||
let mut tx = Transaction::new(tx);
|
||||
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
|
||||
let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
|
||||
let (_, comment_id) = tx.get(&discussion_id, usize::from(comment_id))?;
|
||||
let (_, replies_id) = tx.get(&comment_id, "replies")?;
|
||||
|
||||
let length = tx.length(&replies_id);
|
||||
let reply = tx.insert_object(&replies_id, length, ObjType::Map)?;
|
||||
|
||||
// Nb. Replies don't themselves have replies.
|
||||
tx.put(&reply, "author", author)?;
|
||||
tx.put(&reply, "body", body.trim())?;
|
||||
tx.put(&reply, "timestamp", timestamp)?;
|
||||
tx.put_object(&reply, "reactions", ObjType::Map)?;
|
||||
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
|
||||
|
||||
Ok(change)
|
||||
}
|
||||
|
||||
pub fn react(
|
||||
issue: &mut Automerge,
|
||||
comment_id: CommentId,
|
||||
author: &Author,
|
||||
reactions: &[Reaction],
|
||||
) -> Result<Contents, TransactionError> {
|
||||
issue
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message("React".to_owned()),
|
||||
|tx| {
|
||||
let mut tx = Transaction::new(tx);
|
||||
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
|
||||
let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
|
||||
let (_, comment_id) = if comment_id == CommentId::root() {
|
||||
tx.get(&obj_id, "comment")?
|
||||
} else {
|
||||
tx.get(&discussion_id, usize::from(comment_id) - 1)?
|
||||
};
|
||||
let (_, reactions_id) = tx.get(&comment_id, "reactions")?;
|
||||
|
||||
for reaction in reactions {
|
||||
let key = reaction.emoji.to_string();
|
||||
let reaction_id = if let Some((_, reaction_id)) =
|
||||
tx.try_get(&reactions_id, key)?
|
||||
{
|
||||
reaction_id
|
||||
} else {
|
||||
tx.put_object(&reactions_id, reaction.emoji.to_string(), ObjType::Map)?
|
||||
};
|
||||
tx.put(&reaction_id, author.id.to_human(), true)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
|
||||
|
||||
Ok(change)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::test;
|
||||
|
||||
#[test]
|
||||
fn test_issue_create_and_get() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let timestamp = Timestamp::now();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
|
||||
assert_eq!(issue.title(), "My first issue");
|
||||
assert_eq!(issue.author(), &store.author());
|
||||
assert_eq!(issue.description(), "Blah blah blah.");
|
||||
assert_eq!(issue.comments().len(), 0);
|
||||
assert_eq!(issue.state(), State::Open);
|
||||
assert!(issue.timestamp() >= timestamp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_create_and_change_state() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
issues
|
||||
.lifecycle(
|
||||
&issue_id,
|
||||
State::Closed {
|
||||
reason: CloseReason::Other,
|
||||
},
|
||||
&signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
issue.state(),
|
||||
State::Closed {
|
||||
reason: CloseReason::Other
|
||||
}
|
||||
);
|
||||
|
||||
issues.lifecycle(&issue_id, State::Open, &signer).unwrap();
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
assert_eq!(issue.state(), State::Open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_react() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
let reaction = Reaction::new('🥳').unwrap();
|
||||
issues
|
||||
.react(&issue_id, CommentId::root(), reaction, &signer)
|
||||
.unwrap();
|
||||
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
let count = issue.reactions()[&reaction];
|
||||
|
||||
// TODO: Test multiple reactions from same author and different authors
|
||||
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_reply() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
issues.comment(&issue_id, "Ho ho ho.", &signer).unwrap();
|
||||
issues
|
||||
.reply(&issue_id, CommentId::root(), "Hi hi hi.", &signer)
|
||||
.unwrap();
|
||||
issues
|
||||
.reply(&issue_id, CommentId::root(), "Ha ha ha.", &signer)
|
||||
.unwrap();
|
||||
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
let reply1 = &issue.comments()[0].replies[0];
|
||||
let reply2 = &issue.comments()[0].replies[1];
|
||||
|
||||
assert_eq!(reply1.body, "Hi hi hi.");
|
||||
assert_eq!(reply2.body, "Ha ha ha.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_label() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
let bug_label = Label::new("bug").unwrap();
|
||||
let wontfix_label = Label::new("wontfix").unwrap();
|
||||
|
||||
issues
|
||||
.label(&issue_id, &[bug_label.clone()], &signer)
|
||||
.unwrap();
|
||||
issues
|
||||
.label(&issue_id, &[wontfix_label.clone()], &signer)
|
||||
.unwrap();
|
||||
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
let labels = issue.labels();
|
||||
|
||||
assert!(labels.contains(&bug_label));
|
||||
assert!(labels.contains(&wontfix_label));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_comment() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let now = Timestamp::now();
|
||||
let author = *signer.public_key();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
issues.comment(&issue_id, "Ho ho ho.", &signer).unwrap();
|
||||
issues.comment(&issue_id, "Ha ha ha.", &signer).unwrap();
|
||||
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
let c1 = &issue.comments()[0];
|
||||
let c2 = &issue.comments()[1];
|
||||
|
||||
assert_eq!(&c1.body, "Ho ho ho.");
|
||||
assert_eq!(c1.author.id(), &author);
|
||||
assert_eq!(&c2.body, "Ha ha ha.");
|
||||
assert_eq!(c2.author.id(), &author);
|
||||
assert!(c1.timestamp >= now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_state_serde() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(State::Open).unwrap(),
|
||||
serde_json::json!({ "status": "open" })
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(State::Closed {
|
||||
reason: CloseReason::Solved
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::json!({ "status": "closed", "reason": "solved" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_all() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let author = store.author();
|
||||
|
||||
let contents =
|
||||
events::create(&author, "First", "Blah blah.", Timestamp::new(0), &[]).unwrap();
|
||||
issues
|
||||
.store
|
||||
.create("Create issue", contents, &signer)
|
||||
.unwrap();
|
||||
|
||||
let contents =
|
||||
events::create(&author, "Second", "Blah blah.", Timestamp::new(1), &[]).unwrap();
|
||||
issues
|
||||
.store
|
||||
.create("Create issue", contents, &signer)
|
||||
.unwrap();
|
||||
|
||||
let contents =
|
||||
events::create(&author, "Third", "Blah blah.", Timestamp::new(2), &[]).unwrap();
|
||||
issues
|
||||
.store
|
||||
.create("Create issue", contents, &signer)
|
||||
.unwrap();
|
||||
|
||||
let issues = issues.all().unwrap();
|
||||
assert_eq!(issues.len(), 3);
|
||||
|
||||
// Issues are sorted by timestamp.
|
||||
assert_eq!(issues[0].1.title(), "First");
|
||||
assert_eq!(issues[1].1.title(), "Second");
|
||||
assert_eq!(issues[2].1.title(), "Third");
|
||||
}
|
||||
}
|
||||
|
|
@ -7,11 +7,12 @@ use automerge::{Automerge, ObjType};
|
|||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cob::doc::Document;
|
||||
use crate::cob::shared::*;
|
||||
use crate::cob::store::{Error, FromHistory, Store};
|
||||
use crate::cob::transaction::TransactionError;
|
||||
use crate::cob::{Contents, History, ObjectId, TypeName};
|
||||
use crate::cob::automerge::doc::Document;
|
||||
use crate::cob::automerge::shared::FromHistory;
|
||||
use crate::cob::automerge::store::{Error, Store};
|
||||
use crate::cob::automerge::transaction::TransactionError;
|
||||
use crate::cob::common::*;
|
||||
use crate::cob::{Contents, History, ObjectId, Timestamp, TypeName};
|
||||
use crate::prelude::*;
|
||||
|
||||
pub static TYPENAME: Lazy<TypeName> =
|
||||
|
|
@ -0,0 +1,835 @@
|
|||
#![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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
#![allow(clippy::large_enum_variant)]
|
||||
use std::borrow::Borrow;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use automerge::transaction::Transactable;
|
||||
use automerge::{AutomergeError, ObjType, ScalarValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cob::automerge::doc::{Document, DocumentError};
|
||||
use crate::cob::automerge::store::Error;
|
||||
use crate::cob::automerge::value::{FromValue, Value, ValueError};
|
||||
use crate::cob::common::*;
|
||||
use crate::cob::{History, TypeName};
|
||||
|
||||
/// A type that can be materialized from an event history.
|
||||
/// All collaborative objects implement this trait.
|
||||
pub trait FromHistory: Sized {
|
||||
/// The object type name.
|
||||
fn type_name() -> &'static TypeName;
|
||||
/// Create an object from a history.
|
||||
fn from_history(history: &History) -> Result<Self, Error>;
|
||||
}
|
||||
|
||||
impl<'a> FromValue<'a> for Color {
|
||||
fn from_value(val: Value<'a>) -> Result<Self, ValueError> {
|
||||
let color = String::from_value(val)?;
|
||||
let color = Self::from_str(&color).map_err(|_| ValueError::InvalidValue(color))?;
|
||||
|
||||
Ok(color)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Color {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::ser::Serializer,
|
||||
{
|
||||
let s = self.to_string();
|
||||
serializer.serialize_str(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Deserialize<'a> for Color {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::de::Deserializer<'a>,
|
||||
{
|
||||
let color = String::deserialize(deserializer)?;
|
||||
Self::from_str(&color).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Author> for ScalarValue {
|
||||
fn from(author: &Author) -> Self {
|
||||
ScalarValue::from(author.id.to_human())
|
||||
}
|
||||
}
|
||||
|
||||
impl Comment<()> {
|
||||
pub(super) fn put(
|
||||
&self,
|
||||
tx: &mut automerge::transaction::Transaction,
|
||||
id: &automerge::ObjId,
|
||||
) -> Result<(), AutomergeError> {
|
||||
let comment_id = tx.put_object(id, "comment", ObjType::Map)?;
|
||||
|
||||
assert!(
|
||||
self.reactions.is_empty(),
|
||||
"Cannot put comment with non-empty reactions"
|
||||
);
|
||||
|
||||
tx.put(&comment_id, "body", self.body.trim())?;
|
||||
tx.put(&comment_id, "author", self.author.id().to_string())?;
|
||||
tx.put(&comment_id, "timestamp", self.timestamp)?;
|
||||
tx.put_object(&comment_id, "reactions", ObjType::Map)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Comment<Replies> {
|
||||
pub(super) fn put(
|
||||
&self,
|
||||
tx: &mut automerge::transaction::Transaction,
|
||||
id: &automerge::ObjId,
|
||||
) -> Result<(), AutomergeError> {
|
||||
let comment_id = tx.put_object(id, "comment", ObjType::Map)?;
|
||||
|
||||
assert!(
|
||||
self.reactions.is_empty(),
|
||||
"Cannot put comment with non-empty reactions"
|
||||
);
|
||||
assert!(
|
||||
self.replies.is_empty(),
|
||||
"Cannot put comment with non-empty replies"
|
||||
);
|
||||
|
||||
tx.put(&comment_id, "body", self.body.trim())?;
|
||||
tx.put(&comment_id, "author", self.author.id().to_string())?;
|
||||
tx.put(&comment_id, "timestamp", self.timestamp)?;
|
||||
tx.put_object(&comment_id, "reactions", ObjType::Map)?;
|
||||
tx.put_object(&comment_id, "replies", ObjType::List)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Timestamp> for ScalarValue {
|
||||
fn from(ts: Timestamp) -> Self {
|
||||
ScalarValue::Timestamp(ts.as_secs() as i64)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FromValue<'a> for Timestamp {
|
||||
fn from_value(val: Value<'a>) -> Result<Self, ValueError> {
|
||||
if let Value::Scalar(scalar) = &val {
|
||||
if let ScalarValue::Timestamp(ts) = scalar.borrow() {
|
||||
return Ok(Self::new(*ts as u64));
|
||||
}
|
||||
}
|
||||
Err(ValueError::InvalidValue(val.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub mod lookup {
|
||||
use super::{Author, Comment, HashMap, Reaction, Replies};
|
||||
use super::{Document, DocumentError};
|
||||
|
||||
pub fn comment(doc: Document, obj_id: &automerge::ObjId) -> Result<Comment<()>, DocumentError> {
|
||||
let author = doc.get(obj_id, "author").map(Author::new)?;
|
||||
let body = doc.get(obj_id, "body")?;
|
||||
let timestamp = doc.get(obj_id, "timestamp")?;
|
||||
let reactions: HashMap<Reaction, usize> = doc.map(obj_id, "reactions", |v| *v += 1)?;
|
||||
|
||||
Ok(Comment {
|
||||
author,
|
||||
body,
|
||||
reactions,
|
||||
replies: (),
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn thread(
|
||||
doc: Document,
|
||||
obj_id: &automerge::ObjId,
|
||||
) -> Result<Comment<Replies>, DocumentError> {
|
||||
let comment = self::comment(doc, obj_id)?;
|
||||
let replies = doc.list(obj_id, "replies", self::comment)?;
|
||||
|
||||
Ok(Comment {
|
||||
author: comment.author,
|
||||
body: comment.body,
|
||||
reactions: comment.reactions,
|
||||
replies,
|
||||
timestamp: comment.timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_color() {
|
||||
let c = Color::from_str("#ffccaa").unwrap();
|
||||
assert_eq!(c.to_string(), "#ffccaa".to_owned());
|
||||
assert_eq!(serde_json::to_string(&c).unwrap(), "\"#ffccaa\"".to_owned());
|
||||
assert_eq!(serde_json::from_str::<'_, Color>("\"#ffccaa\"").unwrap(), c);
|
||||
|
||||
let c = Color::from_str("#0000aa").unwrap();
|
||||
assert_eq!(c.to_string(), "#0000aa".to_owned());
|
||||
|
||||
let c = Color::from_str("#aa0000").unwrap();
|
||||
assert_eq!(c.to_string(), "#aa0000".to_owned());
|
||||
|
||||
let c = Color::from_str("#00aa00").unwrap();
|
||||
assert_eq!(c.to_string(), "#00aa00".to_owned());
|
||||
|
||||
Color::from_str("#aa00").unwrap_err();
|
||||
Color::from_str("#abc").unwrap_err();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,12 +6,13 @@ use std::ops::ControlFlow;
|
|||
use automerge::{Automerge, AutomergeError};
|
||||
|
||||
use crate::cob;
|
||||
use crate::cob::doc::DocumentError;
|
||||
use crate::cob::shared::Author;
|
||||
use crate::cob::transaction::TransactionError;
|
||||
use crate::cob::automerge::doc::DocumentError;
|
||||
use crate::cob::automerge::shared::FromHistory;
|
||||
use crate::cob::automerge::transaction::TransactionError;
|
||||
use crate::cob::automerge::{issue, label, patch};
|
||||
use crate::cob::common::Author;
|
||||
use crate::cob::CollaborativeObject;
|
||||
use crate::cob::{issue, label, patch};
|
||||
use crate::cob::{Contents, Create, History, HistoryType, ObjectId, TypeName, Update};
|
||||
use crate::cob::{Contents, Create, HistoryType, ObjectId, TypeName, Update};
|
||||
use crate::crypto::PublicKey;
|
||||
use crate::git;
|
||||
use crate::identity::project;
|
||||
|
|
@ -39,15 +40,6 @@ pub enum Error {
|
|||
NotFound(TypeName, ObjectId),
|
||||
}
|
||||
|
||||
/// A type that can be materialized from an event history.
|
||||
/// All collaborative objects implement this trait.
|
||||
pub trait FromHistory: Sized {
|
||||
/// The object type name.
|
||||
fn type_name() -> &'static TypeName;
|
||||
/// Create an object from a history.
|
||||
fn from_history(history: &History) -> Result<Self, Error>;
|
||||
}
|
||||
|
||||
/// Storage for collaborative objects of a specific type `T` in a single project.
|
||||
pub struct Store<'a, T> {
|
||||
whoami: PublicKey,
|
||||
|
|
@ -3,7 +3,7 @@ use std::ops::{Deref, DerefMut};
|
|||
use automerge::transaction::Transactable;
|
||||
use automerge::AutomergeError;
|
||||
|
||||
use crate::cob::value::Value;
|
||||
use crate::cob::automerge::value::Value;
|
||||
|
||||
/// Wraps an automerge transaction with additional functionality.
|
||||
#[derive(Debug)]
|
||||
|
|
@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||
|
||||
pub use automerge::{ScalarValue, Value};
|
||||
|
||||
use crate::cob::patch::*;
|
||||
use crate::git;
|
||||
use crate::prelude::*;
|
||||
|
||||
|
|
@ -75,3 +76,22 @@ impl<'a> FromValue<'a> for String {
|
|||
val.into_string().map_err(|_| ValueError::InvalidType)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FromValue<'a> for MergeTarget {
|
||||
fn from_value(value: Value<'a>) -> Result<Self, ValueError> {
|
||||
let state = value.to_str().ok_or(ValueError::InvalidType)?;
|
||||
|
||||
match state {
|
||||
"delegates" => Ok(Self::Delegates),
|
||||
_ => Err(ValueError::InvalidValue(value.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MergeTarget> for ScalarValue {
|
||||
fn from(target: MergeTarget) -> Self {
|
||||
match target {
|
||||
MergeTarget::Delegates => ScalarValue::from("delegates"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
/// Author.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Author {
|
||||
pub id: NodeId,
|
||||
}
|
||||
|
||||
impl Author {
|
||||
pub fn new(id: NodeId) -> Self {
|
||||
Self { id }
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &NodeId {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Copy, Clone, PartialOrd, PartialEq, Ord, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Timestamp {
|
||||
seconds: u64,
|
||||
}
|
||||
|
||||
impl Timestamp {
|
||||
pub fn new(seconds: u64) -> Self {
|
||||
Self { seconds }
|
||||
}
|
||||
|
||||
pub fn now() -> Self {
|
||||
#[allow(clippy::unwrap_used)] // Safe because Unix was already invented!
|
||||
let duration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
|
||||
|
||||
Self {
|
||||
seconds: duration.as_secs(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_secs(&self) -> u64 {
|
||||
self.seconds
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Add<u64> for Timestamp {
|
||||
type Output = Self;
|
||||
|
||||
fn add(self, rhs: u64) -> Self::Output {
|
||||
Self {
|
||||
seconds: self.seconds + rhs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ReactionError {
|
||||
#[error("invalid reaction")]
|
||||
InvalidReaction,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Copy, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Reaction {
|
||||
pub emoji: char,
|
||||
}
|
||||
|
||||
impl Reaction {
|
||||
pub fn new(emoji: char) -> Result<Self, ReactionError> {
|
||||
if emoji.is_whitespace() || emoji.is_ascii() || emoji.is_alphanumeric() {
|
||||
return Err(ReactionError::InvalidReaction);
|
||||
}
|
||||
Ok(Self { emoji })
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Reaction {
|
||||
type Err = ReactionError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let mut chars = s.chars();
|
||||
let first = chars.next().ok_or(ReactionError::InvalidReaction)?;
|
||||
|
||||
// Reactions should not consist of more than a single emoji.
|
||||
if chars.next().is_some() {
|
||||
return Err(ReactionError::InvalidReaction);
|
||||
}
|
||||
Reaction::new(first)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum LabelError {
|
||||
#[error("invalid label name: `{0}`")]
|
||||
InvalidName(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Label(String);
|
||||
|
||||
impl Label {
|
||||
pub fn new(name: impl Into<String>) -> Result<Self, LabelError> {
|
||||
let name = name.into();
|
||||
|
||||
if name.chars().any(|c| c.is_whitespace()) || name.is_empty() {
|
||||
return Err(LabelError::InvalidName(name));
|
||||
}
|
||||
Ok(Self(name))
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Label {
|
||||
type Err = LabelError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Label> for String {
|
||||
fn from(Label(name): Label) -> Self {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// RGB color.
|
||||
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
|
||||
pub struct Color(u32);
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ColorConversionError {
|
||||
#[error("invalid format: expect '#rrggbb'")]
|
||||
InvalidFormat,
|
||||
#[error(transparent)]
|
||||
ParseInt(#[from] std::num::ParseIntError),
|
||||
}
|
||||
|
||||
impl fmt::Display for Color {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "#{:06x}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Color {
|
||||
type Err = ColorConversionError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let hex = s.replace('#', "").to_lowercase();
|
||||
|
||||
if hex.chars().count() != 6 {
|
||||
return Err(ColorConversionError::InvalidFormat);
|
||||
}
|
||||
|
||||
match u32::from_str_radix(&hex, 16) {
|
||||
Ok(n) => Ok(Color(n)),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A discussion thread.
|
||||
pub type Discussion = Vec<Comment<Replies>>;
|
||||
|
||||
/// Comment replies.
|
||||
pub type Replies = Vec<Comment>;
|
||||
|
||||
/// Local id of a comment in an issue.
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone)]
|
||||
pub struct CommentId {
|
||||
/// Represents the index of the comment in the thread,
|
||||
/// with `0` being the top-level comment.
|
||||
ix: usize,
|
||||
}
|
||||
|
||||
impl CommentId {
|
||||
/// Root comment.
|
||||
pub const fn root() -> Self {
|
||||
Self { ix: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for CommentId {
|
||||
fn from(ix: usize) -> Self {
|
||||
Self { ix }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CommentId> for usize {
|
||||
fn from(id: CommentId) -> Self {
|
||||
id.ix
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Comment<R = ()> {
|
||||
pub author: Author,
|
||||
pub body: String,
|
||||
pub reactions: HashMap<Reaction, usize>,
|
||||
pub replies: R,
|
||||
pub timestamp: Timestamp,
|
||||
}
|
||||
|
||||
impl<R: Default> Comment<R> {
|
||||
pub fn new(author: Author, body: String, timestamp: Timestamp) -> Self {
|
||||
Self {
|
||||
author,
|
||||
body,
|
||||
reactions: HashMap::default(),
|
||||
replies: R::default(),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,11 @@
|
|||
#![allow(clippy::large_enum_variant)]
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::convert::TryFrom;
|
||||
use std::ops::ControlFlow;
|
||||
use std::str::FromStr;
|
||||
|
||||
use automerge::{Automerge, ObjType, ScalarValue, Value};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cob::doc::{Document, DocumentError};
|
||||
use crate::cob::shared;
|
||||
use crate::cob::shared::*;
|
||||
use crate::cob::store::{Error, FromHistory, Store};
|
||||
use crate::cob::transaction::{Transaction, TransactionError};
|
||||
use crate::cob::value::{FromValue, ValueError};
|
||||
use crate::cob::{Contents, History, ObjectId, TypeName};
|
||||
use crate::prelude::*;
|
||||
use crate::cob::common::*;
|
||||
use crate::cob::{ObjectId, Timestamp, TypeName};
|
||||
|
||||
/// Type name of an issue.
|
||||
pub static TYPENAME: Lazy<TypeName> =
|
||||
|
|
@ -43,7 +33,7 @@ pub enum State {
|
|||
}
|
||||
|
||||
impl State {
|
||||
fn lifecycle_message(self) -> String {
|
||||
pub fn lifecycle_message(self) -> String {
|
||||
match self {
|
||||
State::Open => "Open issue".to_owned(),
|
||||
State::Closed { .. } => "Close issue".to_owned(),
|
||||
|
|
@ -51,37 +41,6 @@ impl State {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<State> for ScalarValue {
|
||||
fn from(state: State) -> Self {
|
||||
match state {
|
||||
State::Open => ScalarValue::from("open"),
|
||||
State::Closed {
|
||||
reason: CloseReason::Solved,
|
||||
} => ScalarValue::from("solved"),
|
||||
State::Closed {
|
||||
reason: CloseReason::Other,
|
||||
} => ScalarValue::from("closed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FromValue<'a> for State {
|
||||
fn from_value(value: Value) -> Result<Self, ValueError> {
|
||||
let state = value.to_str().ok_or(ValueError::InvalidType)?;
|
||||
|
||||
match state {
|
||||
"open" => Ok(Self::Open),
|
||||
"closed" => Ok(Self::Closed {
|
||||
reason: CloseReason::Other,
|
||||
}),
|
||||
"solved" => Ok(Self::Closed {
|
||||
reason: CloseReason::Solved,
|
||||
}),
|
||||
_ => Err(ValueError::InvalidValue(value.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An issue or "ticket".
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Issue {
|
||||
|
|
@ -127,637 +86,3 @@ impl Issue {
|
|||
self.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
impl FromHistory for Issue {
|
||||
fn type_name() -> &'static TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn from_history(history: &History) -> Result<Self, Error> {
|
||||
let doc = history.traverse(Automerge::new(), |mut doc, entry| {
|
||||
let bytes = entry.contents();
|
||||
match automerge::Change::from_bytes(bytes.clone()) {
|
||||
Ok(change) => {
|
||||
doc.apply_changes([change]).ok();
|
||||
}
|
||||
Err(_err) => {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
ControlFlow::Continue(doc)
|
||||
});
|
||||
let issue = Issue::try_from(doc)?;
|
||||
|
||||
Ok(issue)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&History> for Issue {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(history: &History) -> Result<Self, Self::Error> {
|
||||
Issue::from_history(history)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Automerge> for Issue {
|
||||
type Error = DocumentError;
|
||||
|
||||
fn try_from(doc: Automerge) -> Result<Self, Self::Error> {
|
||||
let doc = Document::new(&doc);
|
||||
let obj_id = doc.get_id(automerge::ObjId::Root, "issue")?;
|
||||
let title = doc.get(&obj_id, "title")?;
|
||||
let comment_id = doc.get_id(&obj_id, "comment")?;
|
||||
let author = doc.get(&obj_id, "author").map(Author::new)?;
|
||||
let state = doc.get(&obj_id, "state")?;
|
||||
let timestamp = doc.get(&obj_id, "timestamp")?;
|
||||
|
||||
let comment = shared::lookup::comment(doc, &comment_id)?;
|
||||
let discussion: Discussion = doc.list(&obj_id, "discussion", shared::lookup::thread)?;
|
||||
let labels: HashSet<Label> = doc.keys(&obj_id, "labels")?;
|
||||
|
||||
Ok(Self {
|
||||
title,
|
||||
state,
|
||||
author,
|
||||
comment,
|
||||
discussion,
|
||||
labels,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IssueStore<'a> {
|
||||
store: Store<'a, Issue>,
|
||||
}
|
||||
|
||||
impl<'a> IssueStore<'a> {
|
||||
/// Create a new issue store.
|
||||
pub fn new(store: Store<'a, Issue>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Get an issue by id.
|
||||
pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, Error> {
|
||||
self.store.get(id)
|
||||
}
|
||||
|
||||
/// Create an issue.
|
||||
pub fn create<G: Signer>(
|
||||
&self,
|
||||
title: &str,
|
||||
description: &str,
|
||||
labels: &[Label],
|
||||
signer: &G,
|
||||
) -> Result<IssueId, Error> {
|
||||
let author = self.store.author();
|
||||
let timestamp = Timestamp::now();
|
||||
let contents = events::create(&author, title, description, timestamp, labels)?;
|
||||
let cob = self.store.create("Create issue", contents, signer)?;
|
||||
|
||||
Ok(*cob.id())
|
||||
}
|
||||
|
||||
/// Remove an issue.
|
||||
pub fn remove<G: Signer>(&self, _issue_id: &IssueId, _signer: &G) -> Result<(), Error> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Comment on an issue.
|
||||
pub fn comment<G: Signer>(
|
||||
&self,
|
||||
issue_id: &IssueId,
|
||||
body: &str,
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.store.author();
|
||||
let mut issue = self.store.get_raw(issue_id)?;
|
||||
let timestamp = Timestamp::now();
|
||||
let changes = events::comment(&mut issue, &author, body, timestamp)?;
|
||||
|
||||
self.store
|
||||
.update(*issue_id, "Add comment", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Life-cycle an issue, eg. open or close it.
|
||||
pub fn lifecycle<G: Signer>(
|
||||
&self,
|
||||
issue_id: &IssueId,
|
||||
state: State,
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.store.author();
|
||||
let mut issue = self.store.get_raw(issue_id)?;
|
||||
let changes = events::lifecycle(&mut issue, &author, state)?;
|
||||
|
||||
self.store.update(*issue_id, "Lifecycle", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Label an issue.
|
||||
pub fn label<G: Signer>(
|
||||
&self,
|
||||
issue_id: &IssueId,
|
||||
labels: &[Label],
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.store.author();
|
||||
let mut issue = self.store.get_raw(issue_id)?;
|
||||
let changes = events::label(&mut issue, &author, labels)?;
|
||||
|
||||
self.store.update(*issue_id, "Add label", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// React to an issue comment.
|
||||
pub fn react<G: Signer>(
|
||||
&self,
|
||||
issue_id: &IssueId,
|
||||
comment_id: CommentId,
|
||||
reaction: Reaction,
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.store.author();
|
||||
let mut issue = self.store.get_raw(issue_id)?;
|
||||
let changes = events::react(&mut issue, comment_id, &author, &[reaction])?;
|
||||
|
||||
self.store.update(*issue_id, "React", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reply to an issue comment.
|
||||
pub fn reply<G: Signer>(
|
||||
&self,
|
||||
issue_id: &IssueId,
|
||||
comment_id: CommentId,
|
||||
reply: &str,
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.store.author();
|
||||
let mut issue = self.store.get_raw(issue_id)?;
|
||||
let changes = events::reply(&mut issue, comment_id, &author, reply, Timestamp::now())?;
|
||||
|
||||
self.store.update(*issue_id, "Reply", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all issues, sorted by time.
|
||||
pub fn all(&self) -> Result<Vec<(IssueId, Issue)>, Error> {
|
||||
let mut issues = self.store.list()?;
|
||||
issues.sort_by_key(|(_, i)| i.timestamp);
|
||||
|
||||
Ok(issues)
|
||||
}
|
||||
|
||||
/// Get the issue count.
|
||||
pub fn count(&self) -> Result<usize, Error> {
|
||||
let issues = self.store.list()?;
|
||||
|
||||
Ok(issues.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue events.
|
||||
mod events {
|
||||
use super::*;
|
||||
use automerge::{
|
||||
transaction::{CommitOptions, Transactable},
|
||||
ObjId,
|
||||
};
|
||||
|
||||
pub fn create(
|
||||
author: &Author,
|
||||
title: &str,
|
||||
description: &str,
|
||||
timestamp: Timestamp,
|
||||
labels: &[Label],
|
||||
) -> Result<Contents, TransactionError> {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(TransactionError::InvalidValue("title"));
|
||||
}
|
||||
|
||||
let mut doc = Automerge::new();
|
||||
let _issue = doc
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message("Create issue".to_owned()),
|
||||
|tx| {
|
||||
let issue = tx.put_object(ObjId::Root, "issue", ObjType::Map)?;
|
||||
|
||||
tx.put(&issue, "title", title)?;
|
||||
tx.put(&issue, "author", author)?;
|
||||
tx.put(&issue, "state", State::Open)?;
|
||||
tx.put(&issue, "timestamp", timestamp)?;
|
||||
tx.put_object(&issue, "discussion", ObjType::List)?;
|
||||
|
||||
let labels_id = tx.put_object(&issue, "labels", ObjType::Map)?;
|
||||
for label in labels {
|
||||
tx.put(&labels_id, label.name().trim(), true)?;
|
||||
}
|
||||
|
||||
// Nb. The top-level comment doesn't have a `replies` field.
|
||||
let comment_id = tx.put_object(&issue, "comment", ObjType::Map)?;
|
||||
|
||||
tx.put(&comment_id, "body", description.trim())?;
|
||||
tx.put(&comment_id, "author", author)?;
|
||||
tx.put(&comment_id, "timestamp", timestamp)?;
|
||||
tx.put_object(&comment_id, "reactions", ObjType::Map)?;
|
||||
|
||||
Ok(issue)
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?
|
||||
.result;
|
||||
|
||||
Ok(doc.save_incremental())
|
||||
}
|
||||
|
||||
pub fn comment(
|
||||
issue: &mut Automerge,
|
||||
author: &Author,
|
||||
body: &str,
|
||||
timestamp: Timestamp,
|
||||
) -> Result<Contents, TransactionError> {
|
||||
let _comment = issue
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message("Add comment".to_owned()),
|
||||
|tx| {
|
||||
let mut tx = Transaction::new(tx);
|
||||
let (_obj, obj_id) = tx.get(ObjId::Root, "issue")?;
|
||||
let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
|
||||
|
||||
let length = tx.length(&discussion_id);
|
||||
let comment = tx.insert_object(&discussion_id, length, ObjType::Map)?;
|
||||
|
||||
tx.put(&comment, "author", author)?;
|
||||
tx.put(&comment, "body", body.trim())?;
|
||||
tx.put(&comment, "timestamp", timestamp)?;
|
||||
tx.put_object(&comment, "replies", ObjType::List)?;
|
||||
tx.put_object(&comment, "reactions", ObjType::Map)?;
|
||||
|
||||
Ok(comment)
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?
|
||||
.result;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
|
||||
|
||||
Ok(change)
|
||||
}
|
||||
|
||||
pub fn lifecycle(
|
||||
issue: &mut Automerge,
|
||||
author: &Author,
|
||||
state: State,
|
||||
) -> Result<Contents, TransactionError> {
|
||||
issue
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message(state.lifecycle_message()),
|
||||
|tx| {
|
||||
let mut tx = Transaction::new(tx);
|
||||
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
|
||||
tx.put(&obj_id, "state", state)?;
|
||||
tx.put(&obj_id, "author", author)?;
|
||||
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
|
||||
|
||||
Ok(change)
|
||||
}
|
||||
|
||||
pub fn label(
|
||||
issue: &mut Automerge,
|
||||
_author: &Author,
|
||||
labels: &[Label],
|
||||
) -> Result<Contents, TransactionError> {
|
||||
issue
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message("Label issue".to_owned()),
|
||||
|tx| {
|
||||
let mut tx = Transaction::new(tx);
|
||||
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
|
||||
let (_, labels_id) = tx.get(&obj_id, "labels")?;
|
||||
|
||||
for label in labels {
|
||||
tx.put(&labels_id, label.name().trim(), true)?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
|
||||
|
||||
Ok(change)
|
||||
}
|
||||
|
||||
pub fn reply(
|
||||
issue: &mut Automerge,
|
||||
comment_id: CommentId,
|
||||
author: &Author,
|
||||
body: &str,
|
||||
timestamp: Timestamp,
|
||||
) -> Result<Contents, TransactionError> {
|
||||
issue
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message("Reply".to_owned()),
|
||||
|tx| {
|
||||
let mut tx = Transaction::new(tx);
|
||||
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
|
||||
let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
|
||||
let (_, comment_id) = tx.get(&discussion_id, usize::from(comment_id))?;
|
||||
let (_, replies_id) = tx.get(&comment_id, "replies")?;
|
||||
|
||||
let length = tx.length(&replies_id);
|
||||
let reply = tx.insert_object(&replies_id, length, ObjType::Map)?;
|
||||
|
||||
// Nb. Replies don't themselves have replies.
|
||||
tx.put(&reply, "author", author)?;
|
||||
tx.put(&reply, "body", body.trim())?;
|
||||
tx.put(&reply, "timestamp", timestamp)?;
|
||||
tx.put_object(&reply, "reactions", ObjType::Map)?;
|
||||
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
|
||||
|
||||
Ok(change)
|
||||
}
|
||||
|
||||
pub fn react(
|
||||
issue: &mut Automerge,
|
||||
comment_id: CommentId,
|
||||
author: &Author,
|
||||
reactions: &[Reaction],
|
||||
) -> Result<Contents, TransactionError> {
|
||||
issue
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message("React".to_owned()),
|
||||
|tx| {
|
||||
let mut tx = Transaction::new(tx);
|
||||
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
|
||||
let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
|
||||
let (_, comment_id) = if comment_id == CommentId::root() {
|
||||
tx.get(&obj_id, "comment")?
|
||||
} else {
|
||||
tx.get(&discussion_id, usize::from(comment_id) - 1)?
|
||||
};
|
||||
let (_, reactions_id) = tx.get(&comment_id, "reactions")?;
|
||||
|
||||
for reaction in reactions {
|
||||
let key = reaction.emoji.to_string();
|
||||
let reaction_id = if let Some((_, reaction_id)) =
|
||||
tx.try_get(&reactions_id, key)?
|
||||
{
|
||||
reaction_id
|
||||
} else {
|
||||
tx.put_object(&reactions_id, reaction.emoji.to_string(), ObjType::Map)?
|
||||
};
|
||||
tx.put(&reaction_id, author.id.to_human(), true)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
|
||||
|
||||
Ok(change)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::test;
|
||||
|
||||
#[test]
|
||||
fn test_issue_create_and_get() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let timestamp = Timestamp::now();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
|
||||
assert_eq!(issue.title(), "My first issue");
|
||||
assert_eq!(issue.author(), &store.author());
|
||||
assert_eq!(issue.description(), "Blah blah blah.");
|
||||
assert_eq!(issue.comments().len(), 0);
|
||||
assert_eq!(issue.state(), State::Open);
|
||||
assert!(issue.timestamp() >= timestamp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_create_and_change_state() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
issues
|
||||
.lifecycle(
|
||||
&issue_id,
|
||||
State::Closed {
|
||||
reason: CloseReason::Other,
|
||||
},
|
||||
&signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
issue.state(),
|
||||
State::Closed {
|
||||
reason: CloseReason::Other
|
||||
}
|
||||
);
|
||||
|
||||
issues.lifecycle(&issue_id, State::Open, &signer).unwrap();
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
assert_eq!(issue.state(), State::Open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_react() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
let reaction = Reaction::new('🥳').unwrap();
|
||||
issues
|
||||
.react(&issue_id, CommentId::root(), reaction, &signer)
|
||||
.unwrap();
|
||||
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
let count = issue.reactions()[&reaction];
|
||||
|
||||
// TODO: Test multiple reactions from same author and different authors
|
||||
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_reply() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
issues.comment(&issue_id, "Ho ho ho.", &signer).unwrap();
|
||||
issues
|
||||
.reply(&issue_id, CommentId::root(), "Hi hi hi.", &signer)
|
||||
.unwrap();
|
||||
issues
|
||||
.reply(&issue_id, CommentId::root(), "Ha ha ha.", &signer)
|
||||
.unwrap();
|
||||
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
let reply1 = &issue.comments()[0].replies[0];
|
||||
let reply2 = &issue.comments()[0].replies[1];
|
||||
|
||||
assert_eq!(reply1.body, "Hi hi hi.");
|
||||
assert_eq!(reply2.body, "Ha ha ha.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_label() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
let bug_label = Label::new("bug").unwrap();
|
||||
let wontfix_label = Label::new("wontfix").unwrap();
|
||||
|
||||
issues
|
||||
.label(&issue_id, &[bug_label.clone()], &signer)
|
||||
.unwrap();
|
||||
issues
|
||||
.label(&issue_id, &[wontfix_label.clone()], &signer)
|
||||
.unwrap();
|
||||
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
let labels = issue.labels();
|
||||
|
||||
assert!(labels.contains(&bug_label));
|
||||
assert!(labels.contains(&wontfix_label));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_comment() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let now = Timestamp::now();
|
||||
let author = *signer.public_key();
|
||||
let issue_id = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
issues.comment(&issue_id, "Ho ho ho.", &signer).unwrap();
|
||||
issues.comment(&issue_id, "Ha ha ha.", &signer).unwrap();
|
||||
|
||||
let issue = issues.get(&issue_id).unwrap().unwrap();
|
||||
let c1 = &issue.comments()[0];
|
||||
let c2 = &issue.comments()[1];
|
||||
|
||||
assert_eq!(&c1.body, "Ho ho ho.");
|
||||
assert_eq!(c1.author.id(), &author);
|
||||
assert_eq!(&c2.body, "Ha ha ha.");
|
||||
assert_eq!(c2.author.id(), &author);
|
||||
assert!(c1.timestamp >= now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_state_serde() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(State::Open).unwrap(),
|
||||
serde_json::json!({ "status": "open" })
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(State::Closed {
|
||||
reason: CloseReason::Solved
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::json!({ "status": "closed", "reason": "solved" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_all() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
||||
let issues = store.issues();
|
||||
let author = store.author();
|
||||
|
||||
let contents =
|
||||
events::create(&author, "First", "Blah blah.", Timestamp::new(0), &[]).unwrap();
|
||||
issues
|
||||
.store
|
||||
.create("Create issue", contents, &signer)
|
||||
.unwrap();
|
||||
|
||||
let contents =
|
||||
events::create(&author, "Second", "Blah blah.", Timestamp::new(1), &[]).unwrap();
|
||||
issues
|
||||
.store
|
||||
.create("Create issue", contents, &signer)
|
||||
.unwrap();
|
||||
|
||||
let contents =
|
||||
events::create(&author, "Third", "Blah blah.", Timestamp::new(2), &[]).unwrap();
|
||||
issues
|
||||
.store
|
||||
.create("Create issue", contents, &signer)
|
||||
.unwrap();
|
||||
|
||||
let issues = issues.all().unwrap();
|
||||
assert_eq!(issues.len(), 3);
|
||||
|
||||
// Issues are sorted by timestamp.
|
||||
assert_eq!(issues[0].1.title(), "First");
|
||||
assert_eq!(issues[1].1.title(), "Second");
|
||||
assert_eq!(issues[2].1.title(), "Third");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,14 @@
|
|||
#![allow(clippy::too_many_arguments)]
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::convert::TryFrom;
|
||||
use std::fmt;
|
||||
use std::ops::{ControlFlow, Deref, RangeInclusive};
|
||||
use std::ops::RangeInclusive;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use automerge::transaction::Transactable;
|
||||
use automerge::{Automerge, AutomergeError, ObjType, ScalarValue, Value};
|
||||
use nonempty::NonEmpty;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cob::doc::{Document, DocumentError};
|
||||
use crate::cob::shared;
|
||||
use crate::cob::shared::*;
|
||||
use crate::cob::store::Error;
|
||||
use crate::cob::store::{FromHistory, Store};
|
||||
use crate::cob::transaction::{Transaction, TransactionError};
|
||||
use crate::cob::value::{FromValue, ValueError};
|
||||
use crate::cob::{Contents, History, ObjectId, TypeName};
|
||||
use crate::cob::common::*;
|
||||
use crate::cob::{ObjectId, Timestamp, TypeName};
|
||||
use crate::git;
|
||||
use crate::prelude::*;
|
||||
|
||||
|
|
@ -47,25 +36,6 @@ pub enum MergeTarget {
|
|||
Delegates,
|
||||
}
|
||||
|
||||
impl From<MergeTarget> for ScalarValue {
|
||||
fn from(target: MergeTarget) -> Self {
|
||||
match target {
|
||||
MergeTarget::Delegates => ScalarValue::from("delegates"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FromValue<'a> for MergeTarget {
|
||||
fn from_value(value: Value<'a>) -> Result<Self, ValueError> {
|
||||
let state = value.to_str().ok_or(ValueError::InvalidType)?;
|
||||
|
||||
match state {
|
||||
"delegates" => Ok(Self::Delegates),
|
||||
_ => Err(ValueError::InvalidValue(value.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A patch to a repository.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Patch<T = ()>
|
||||
|
|
@ -118,264 +88,6 @@ impl Patch {
|
|||
}
|
||||
}
|
||||
|
||||
impl FromHistory for Patch {
|
||||
fn type_name() -> &'static TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn from_history(history: &History) -> Result<Self, Error> {
|
||||
Patch::try_from(history)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Document<'_>> for Patch {
|
||||
type Error = DocumentError;
|
||||
|
||||
fn try_from(doc: Document) -> Result<Self, Self::Error> {
|
||||
let obj_id = doc.get_id(automerge::ObjId::Root, "patch")?;
|
||||
let title = doc.get(&obj_id, "title")?;
|
||||
let author = doc.get(&obj_id, "author")?;
|
||||
let state = doc.get(&obj_id, "state")?;
|
||||
let target = doc.get(&obj_id, "target")?;
|
||||
let timestamp = doc.get(&obj_id, "timestamp")?;
|
||||
let revisions = doc.list(&obj_id, "revisions", lookup::revision)?;
|
||||
let labels: HashSet<Label> = doc.keys(&obj_id, "labels")?;
|
||||
let revisions =
|
||||
NonEmpty::from_vec(revisions).ok_or(DocumentError::EmptyList("revisions"))?;
|
||||
let author: Author = Author::new(author);
|
||||
|
||||
Ok(Self {
|
||||
author,
|
||||
title,
|
||||
state,
|
||||
target,
|
||||
labels,
|
||||
revisions,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&History> for Patch {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(history: &History) -> Result<Self, Self::Error> {
|
||||
let doc = history.traverse(Automerge::new(), |mut doc, entry| {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PatchStore<'a> {
|
||||
store: Store<'a, Patch>,
|
||||
}
|
||||
|
||||
impl<'a> Deref for PatchStore<'a> {
|
||||
type Target = Store<'a, Patch>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.store
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> PatchStore<'a> {
|
||||
/// Create a new patch store.
|
||||
pub fn new(store: Store<'a, Patch>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Get a patch by id.
|
||||
pub fn get(&self, id: &ObjectId) -> Result<Option<Patch>, Error> {
|
||||
self.store.get(id)
|
||||
}
|
||||
|
||||
/// Create a patch.
|
||||
pub fn create<G: Signer>(
|
||||
&self,
|
||||
title: &str,
|
||||
description: &str,
|
||||
target: MergeTarget,
|
||||
base: impl Into<git::Oid>,
|
||||
oid: impl Into<git::Oid>,
|
||||
labels: &[Label],
|
||||
signer: &G,
|
||||
) -> Result<PatchId, Error> {
|
||||
let author = self.author();
|
||||
let timestamp = Timestamp::now();
|
||||
let revision = Revision::new(
|
||||
author.clone(),
|
||||
base.into(),
|
||||
oid.into(),
|
||||
description.to_owned(),
|
||||
timestamp,
|
||||
);
|
||||
let contents = events::create(&author, title, &revision, target, timestamp, labels)?;
|
||||
let cob = self.store.create("Create patch", contents, signer)?;
|
||||
|
||||
Ok(*cob.id())
|
||||
}
|
||||
|
||||
/// Comment on a patch.
|
||||
pub fn comment<G: Signer>(
|
||||
&self,
|
||||
patch_id: &PatchId,
|
||||
revision_ix: RevisionIx,
|
||||
body: &str,
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.author();
|
||||
let mut patch = self.store.get_raw(patch_id)?;
|
||||
let timestamp = Timestamp::now();
|
||||
let changes = events::comment(&mut patch, revision_ix, &author, body, timestamp)?;
|
||||
|
||||
self.store
|
||||
.update(*patch_id, "Add comment", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a patch with new code. Creates a new revision.
|
||||
pub fn update<G: Signer>(
|
||||
&self,
|
||||
patch_id: &PatchId,
|
||||
comment: impl ToString,
|
||||
base: impl Into<git::Oid>,
|
||||
oid: impl Into<git::Oid>,
|
||||
signer: &G,
|
||||
) -> Result<RevisionIx, Error> {
|
||||
let author = self.author();
|
||||
let timestamp = Timestamp::now();
|
||||
let revision = Revision::new(
|
||||
author,
|
||||
base.into(),
|
||||
oid.into(),
|
||||
comment.to_string(),
|
||||
timestamp,
|
||||
);
|
||||
|
||||
let mut patch = self.get_raw(patch_id)?;
|
||||
let (revision_ix, changes) = events::update(&mut patch, revision)?;
|
||||
|
||||
self.store
|
||||
.update(*patch_id, "Update patch", changes, signer)?;
|
||||
|
||||
Ok(revision_ix)
|
||||
}
|
||||
|
||||
/// Reply to a patch comment.
|
||||
pub fn reply<G: Signer>(
|
||||
&self,
|
||||
patch_id: &PatchId,
|
||||
revision_ix: RevisionIx,
|
||||
comment_id: CommentId,
|
||||
reply: &str,
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let author = self.author();
|
||||
let mut patch = self.get_raw(patch_id)?;
|
||||
let changes = events::reply(
|
||||
&mut patch,
|
||||
revision_ix,
|
||||
comment_id,
|
||||
&author,
|
||||
reply,
|
||||
Timestamp::now(),
|
||||
)?;
|
||||
|
||||
self.store.update(*patch_id, "Reply", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Review a patch revision.
|
||||
pub fn review<G: Signer>(
|
||||
&self,
|
||||
patch_id: &PatchId,
|
||||
revision_ix: RevisionIx,
|
||||
verdict: Option<Verdict>,
|
||||
comment: impl Into<String>,
|
||||
inline: Vec<CodeComment>,
|
||||
signer: &G,
|
||||
) -> Result<(), Error> {
|
||||
let timestamp = Timestamp::now();
|
||||
let review = Review::new(self.author(), verdict, comment, inline, timestamp);
|
||||
|
||||
let mut patch = self.get_raw(patch_id)?;
|
||||
let (_, changes) = events::review(&mut patch, revision_ix, review)?;
|
||||
|
||||
self.store
|
||||
.update(*patch_id, "Review patch", changes, signer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge a patch revision.
|
||||
pub fn merge<G: Signer>(
|
||||
&self,
|
||||
patch_id: &PatchId,
|
||||
revision_ix: RevisionIx,
|
||||
commit: git::Oid,
|
||||
signer: &G,
|
||||
) -> Result<Merge, Error> {
|
||||
let timestamp = Timestamp::now();
|
||||
let merge = Merge {
|
||||
node: *signer.public_key(),
|
||||
commit,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
let mut patch = self.get_raw(patch_id)?;
|
||||
let changes = events::merge(&mut patch, revision_ix, &merge)?;
|
||||
|
||||
self.store
|
||||
.update(*patch_id, "Merge revision", changes, signer)?;
|
||||
|
||||
Ok(merge)
|
||||
}
|
||||
|
||||
/// Get the patch count.
|
||||
pub fn count(&self) -> Result<usize, Error> {
|
||||
let cobs = self.store.list()?;
|
||||
|
||||
Ok(cobs.len())
|
||||
}
|
||||
|
||||
/// Get all patches for this project.
|
||||
pub fn all(&self) -> Result<Vec<(PatchId, Patch)>, Error> {
|
||||
let mut patches = self.store.list()?;
|
||||
patches.sort_by_key(|(_, p)| p.timestamp);
|
||||
|
||||
Ok(patches)
|
||||
}
|
||||
|
||||
/// Get proposed patches.
|
||||
pub fn proposed(&self) -> Result<impl Iterator<Item = (PatchId, Patch)>, Error> {
|
||||
let all = self.all()?;
|
||||
|
||||
Ok(all.into_iter().filter(|(_, p)| p.is_proposed()))
|
||||
}
|
||||
|
||||
/// Get patches proposed by the given key.
|
||||
pub fn proposed_by<'b>(
|
||||
&'b self,
|
||||
who: &'b PublicKey,
|
||||
) -> Result<impl Iterator<Item = (PatchId, Patch)> + '_, Error> {
|
||||
Ok(self.proposed()?.filter(move |(_, p)| p.author.id() == who))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum State {
|
||||
|
|
@ -384,29 +96,6 @@ pub enum State {
|
|||
Archived,
|
||||
}
|
||||
|
||||
impl From<State> for ScalarValue {
|
||||
fn from(state: State) -> Self {
|
||||
match state {
|
||||
State::Proposed => ScalarValue::from("proposed"),
|
||||
State::Draft => ScalarValue::from("draft"),
|
||||
State::Archived => ScalarValue::from("archived"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FromValue<'a> for State {
|
||||
fn from_value(value: Value<'a>) -> Result<Self, ValueError> {
|
||||
let state = value.to_str().ok_or(ValueError::InvalidType)?;
|
||||
|
||||
match state {
|
||||
"proposed" => Ok(Self::Proposed),
|
||||
"draft" => Ok(Self::Draft),
|
||||
"archived" => Ok(Self::Archived),
|
||||
_ => Err(ValueError::InvalidValue(value.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A patch revision.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Revision<T = ()> {
|
||||
|
|
@ -459,40 +148,6 @@ impl Revision {
|
|||
pub fn author(&self) -> &Author {
|
||||
&self.comment.author
|
||||
}
|
||||
|
||||
/// Put this object into an automerge document.
|
||||
fn put<'a>(
|
||||
&self,
|
||||
mut tx: impl AsMut<automerge::transaction::Transaction<'a>>,
|
||||
id: &automerge::ObjId,
|
||||
) -> Result<(), AutomergeError> {
|
||||
assert!(
|
||||
self.merges.is_empty(),
|
||||
"Cannot put revision with non-empty merges"
|
||||
);
|
||||
assert!(
|
||||
self.reviews.is_empty(),
|
||||
"Cannot put revision with non-empty reviews"
|
||||
);
|
||||
assert!(
|
||||
self.discussion.is_empty(),
|
||||
"Cannot put revision with non-empty discussion"
|
||||
);
|
||||
let tx = tx.as_mut();
|
||||
|
||||
tx.put(id, "id", self.id.to_string())?;
|
||||
tx.put(id, "oid", self.oid.to_string())?;
|
||||
tx.put(id, "base", self.base.to_string())?;
|
||||
|
||||
self.comment.put(tx, id)?;
|
||||
|
||||
tx.put_object(id, "discussion", ObjType::List)?;
|
||||
tx.put_object(id, "reviews", ObjType::Map)?;
|
||||
tx.put_object(id, "merges", ObjType::List)?;
|
||||
tx.put(id, "timestamp", self.timestamp)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A merged patch revision.
|
||||
|
|
@ -525,21 +180,6 @@ impl fmt::Display for Verdict {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<Verdict> for ScalarValue {
|
||||
fn from(verdict: Verdict) -> Self {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let s = serde_json::to_string(&verdict).unwrap(); // Cannot fail.
|
||||
ScalarValue::from(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FromValue<'a> for Verdict {
|
||||
fn from_value(value: Value) -> Result<Self, ValueError> {
|
||||
let verdict = value.to_str().ok_or(ValueError::InvalidType)?;
|
||||
serde_json::from_str(verdict).map_err(|e| ValueError::Other(Arc::new(e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Code location, used for attaching comments.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CodeLocation {
|
||||
|
|
@ -593,487 +233,4 @@ impl Review {
|
|||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Put this object into an automerge document.
|
||||
fn put<'a>(
|
||||
&self,
|
||||
mut tx: impl AsMut<automerge::transaction::Transaction<'a>>,
|
||||
id: &automerge::ObjId,
|
||||
) -> Result<(), AutomergeError> {
|
||||
assert!(
|
||||
self.inline.is_empty(),
|
||||
"Cannot put review with non-empty inline comments"
|
||||
);
|
||||
let tx = tx.as_mut();
|
||||
|
||||
tx.put(id, "author", &self.author)?;
|
||||
tx.put(
|
||||
id,
|
||||
"verdict",
|
||||
if let Some(v) = self.verdict {
|
||||
v.into()
|
||||
} else {
|
||||
ScalarValue::Null
|
||||
},
|
||||
)?;
|
||||
|
||||
self.comment.put(tx, id)?;
|
||||
|
||||
tx.put_object(id, "inline", ObjType::List)?;
|
||||
tx.put(id, "timestamp", self.timestamp)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod lookup {
|
||||
use super::*;
|
||||
|
||||
pub fn revision(
|
||||
doc: Document,
|
||||
revision_id: &automerge::ObjId,
|
||||
) -> Result<Revision, DocumentError> {
|
||||
let comment_id = doc.get_id(revision_id, "comment")?;
|
||||
let reviews_id = doc.get_id(revision_id, "reviews")?;
|
||||
let id = doc.get(revision_id, "id")?;
|
||||
let base = doc.get(revision_id, "base")?;
|
||||
let oid = doc.get(revision_id, "oid")?;
|
||||
let timestamp = doc.get(revision_id, "timestamp")?;
|
||||
let merges: Vec<Merge> = doc.list(revision_id, "merges", self::merge)?;
|
||||
|
||||
// Discussion.
|
||||
let comment = shared::lookup::comment(doc, &comment_id)?;
|
||||
let discussion: Discussion = doc.list(revision_id, "discussion", shared::lookup::thread)?;
|
||||
|
||||
// Reviews.
|
||||
let mut reviews: HashMap<NodeId, Review> = HashMap::new();
|
||||
for key in (*doc).keys(&reviews_id) {
|
||||
let review_id = doc.get_id(&reviews_id, key)?;
|
||||
let review = self::review(doc, &review_id)?;
|
||||
|
||||
reviews.insert(*review.author.id(), review);
|
||||
}
|
||||
|
||||
Ok(Revision {
|
||||
id,
|
||||
base,
|
||||
oid,
|
||||
comment,
|
||||
discussion,
|
||||
reviews,
|
||||
merges,
|
||||
changeset: (),
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn merge(doc: Document, obj_id: &automerge::ObjId) -> Result<Merge, DocumentError> {
|
||||
let node = doc.get(obj_id, "peer")?;
|
||||
let commit = doc.get(obj_id, "commit")?;
|
||||
let timestamp = doc.get(obj_id, "timestamp")?;
|
||||
|
||||
Ok(Merge {
|
||||
node,
|
||||
commit,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn review(doc: Document, obj_id: &automerge::ObjId) -> Result<Review, DocumentError> {
|
||||
let author = doc.get(obj_id, "author")?;
|
||||
let verdict = doc.get(obj_id, "verdict")?;
|
||||
let timestamp = doc.get(obj_id, "timestamp")?;
|
||||
let comment = doc.lookup(obj_id, "comment", shared::lookup::thread)?;
|
||||
let inline = vec![];
|
||||
|
||||
Ok(Review {
|
||||
author: Author::new(author),
|
||||
comment,
|
||||
verdict,
|
||||
inline,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch events.
|
||||
mod events {
|
||||
use super::*;
|
||||
use automerge::{
|
||||
transaction::{CommitOptions, Transactable},
|
||||
ObjId,
|
||||
};
|
||||
|
||||
pub fn create(
|
||||
author: &Author,
|
||||
title: &str,
|
||||
revision: &Revision,
|
||||
target: MergeTarget,
|
||||
timestamp: Timestamp,
|
||||
labels: &[Label],
|
||||
) -> Result<Contents, TransactionError> {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(TransactionError::InvalidValue("title"));
|
||||
}
|
||||
|
||||
let mut doc = Automerge::new();
|
||||
let _patch = doc
|
||||
.transact_with::<_, _, TransactionError, _, ()>(
|
||||
|_| CommitOptions::default().with_message("Create patch".to_owned()),
|
||||
|tx| {
|
||||
let mut tx = Transaction::new(tx);
|
||||
let patch_id = tx.put_object(ObjId::Root, "patch", ObjType::Map)?;
|
||||
|
||||
tx.put(&patch_id, "title", title)?;
|
||||
tx.put(&patch_id, "author", author)?;
|
||||
tx.put(&patch_id, "state", State::Proposed)?;
|
||||
tx.put(&patch_id, "target", target)?;
|
||||
tx.put(&patch_id, "timestamp", timestamp)?;
|
||||
|
||||
let labels_id = tx.put_object(&patch_id, "labels", ObjType::Map)?;
|
||||
for label in labels {
|
||||
tx.put(&labels_id, label.name().trim(), true)?;
|
||||
}
|
||||
|
||||
let revisions_id = tx.put_object(&patch_id, "revisions", ObjType::List)?;
|
||||
let revision_id = tx.insert_object(&revisions_id, 0, ObjType::Map)?;
|
||||
|
||||
revision.put(tx, &revision_id)?;
|
||||
|
||||
Ok(patch_id)
|
||||
},
|
||||
)
|
||||
.map_err(|failure| failure.error)?
|
||||
.result;
|
||||
|
||||
Ok(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 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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,394 +0,0 @@
|
|||
#![allow(clippy::large_enum_variant)]
|
||||
use std::borrow::Borrow;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::hash::Hash;
|
||||
use std::str::FromStr;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use automerge::transaction::Transactable;
|
||||
use automerge::{AutomergeError, ObjType, ScalarValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cob::doc::{Document, DocumentError};
|
||||
use crate::cob::value::{FromValue, Value, ValueError};
|
||||
use crate::prelude::*;
|
||||
|
||||
/// A discussion thread.
|
||||
pub type Discussion = Vec<Comment<Replies>>;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ReactionError {
|
||||
#[error("invalid reaction")]
|
||||
InvalidReaction,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Copy, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Reaction {
|
||||
pub emoji: char,
|
||||
}
|
||||
|
||||
impl Reaction {
|
||||
pub fn new(emoji: char) -> Result<Self, ReactionError> {
|
||||
if emoji.is_whitespace() || emoji.is_ascii() || emoji.is_alphanumeric() {
|
||||
return Err(ReactionError::InvalidReaction);
|
||||
}
|
||||
Ok(Self { emoji })
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Reaction {
|
||||
type Err = ReactionError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let mut chars = s.chars();
|
||||
let first = chars.next().ok_or(ReactionError::InvalidReaction)?;
|
||||
|
||||
// Reactions should not consist of more than a single emoji.
|
||||
if chars.next().is_some() {
|
||||
return Err(ReactionError::InvalidReaction);
|
||||
}
|
||||
Reaction::new(first)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum LabelError {
|
||||
#[error("invalid label name: `{0}`")]
|
||||
InvalidName(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Label(String);
|
||||
|
||||
impl Label {
|
||||
pub fn new(name: impl Into<String>) -> Result<Self, LabelError> {
|
||||
let name = name.into();
|
||||
|
||||
if name.chars().any(|c| c.is_whitespace()) || name.is_empty() {
|
||||
return Err(LabelError::InvalidName(name));
|
||||
}
|
||||
Ok(Self(name))
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Label {
|
||||
type Err = LabelError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Label> for String {
|
||||
fn from(Label(name): Label) -> Self {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// RGB color.
|
||||
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
|
||||
pub struct Color(u32);
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ColorConversionError {
|
||||
#[error("invalid format: expect '#rrggbb'")]
|
||||
InvalidFormat,
|
||||
#[error(transparent)]
|
||||
ParseInt(#[from] std::num::ParseIntError),
|
||||
}
|
||||
|
||||
impl fmt::Display for Color {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "#{:06x}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Color {
|
||||
type Err = ColorConversionError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let hex = s.replace('#', "").to_lowercase();
|
||||
|
||||
if hex.chars().count() != 6 {
|
||||
return Err(ColorConversionError::InvalidFormat);
|
||||
}
|
||||
|
||||
match u32::from_str_radix(&hex, 16) {
|
||||
Ok(n) => Ok(Color(n)),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FromValue<'a> for Color {
|
||||
fn from_value(val: Value<'a>) -> Result<Self, ValueError> {
|
||||
let color = String::from_value(val)?;
|
||||
let color = Self::from_str(&color).map_err(|_| ValueError::InvalidValue(color))?;
|
||||
|
||||
Ok(color)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Color {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::ser::Serializer,
|
||||
{
|
||||
let s = self.to_string();
|
||||
serializer.serialize_str(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Deserialize<'a> for Color {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::de::Deserializer<'a>,
|
||||
{
|
||||
let color = String::deserialize(deserializer)?;
|
||||
Self::from_str(&color).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Author.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Author {
|
||||
pub id: NodeId,
|
||||
}
|
||||
|
||||
impl Author {
|
||||
pub fn new(id: NodeId) -> Self {
|
||||
Self { id }
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &NodeId {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Author> for ScalarValue {
|
||||
fn from(author: &Author) -> Self {
|
||||
ScalarValue::from(author.id.to_human())
|
||||
}
|
||||
}
|
||||
|
||||
/// Local id of a comment in an issue.
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone)]
|
||||
pub struct CommentId {
|
||||
/// Represents the index of the comment in the thread,
|
||||
/// with `0` being the top-level comment.
|
||||
ix: usize,
|
||||
}
|
||||
|
||||
impl CommentId {
|
||||
/// Root comment.
|
||||
pub const fn root() -> Self {
|
||||
Self { ix: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for CommentId {
|
||||
fn from(ix: usize) -> Self {
|
||||
Self { ix }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CommentId> for usize {
|
||||
fn from(id: CommentId) -> Self {
|
||||
id.ix
|
||||
}
|
||||
}
|
||||
|
||||
/// Comment replies.
|
||||
pub type Replies = Vec<Comment>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Comment<R = ()> {
|
||||
pub author: Author,
|
||||
pub body: String,
|
||||
pub reactions: HashMap<Reaction, usize>,
|
||||
pub replies: R,
|
||||
pub timestamp: Timestamp,
|
||||
}
|
||||
|
||||
impl<R: Default> Comment<R> {
|
||||
pub fn new(author: Author, body: String, timestamp: Timestamp) -> Self {
|
||||
Self {
|
||||
author,
|
||||
body,
|
||||
reactions: HashMap::default(),
|
||||
replies: R::default(),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Comment<()> {
|
||||
pub(super) fn put(
|
||||
&self,
|
||||
tx: &mut automerge::transaction::Transaction,
|
||||
id: &automerge::ObjId,
|
||||
) -> Result<(), AutomergeError> {
|
||||
let comment_id = tx.put_object(id, "comment", ObjType::Map)?;
|
||||
|
||||
assert!(
|
||||
self.reactions.is_empty(),
|
||||
"Cannot put comment with non-empty reactions"
|
||||
);
|
||||
|
||||
tx.put(&comment_id, "body", self.body.trim())?;
|
||||
tx.put(&comment_id, "author", self.author.id().to_string())?;
|
||||
tx.put(&comment_id, "timestamp", self.timestamp)?;
|
||||
tx.put_object(&comment_id, "reactions", ObjType::Map)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Comment<Replies> {
|
||||
pub(super) fn put(
|
||||
&self,
|
||||
tx: &mut automerge::transaction::Transaction,
|
||||
id: &automerge::ObjId,
|
||||
) -> Result<(), AutomergeError> {
|
||||
let comment_id = tx.put_object(id, "comment", ObjType::Map)?;
|
||||
|
||||
assert!(
|
||||
self.reactions.is_empty(),
|
||||
"Cannot put comment with non-empty reactions"
|
||||
);
|
||||
assert!(
|
||||
self.replies.is_empty(),
|
||||
"Cannot put comment with non-empty replies"
|
||||
);
|
||||
|
||||
tx.put(&comment_id, "body", self.body.trim())?;
|
||||
tx.put(&comment_id, "author", self.author.id().to_string())?;
|
||||
tx.put(&comment_id, "timestamp", self.timestamp)?;
|
||||
tx.put_object(&comment_id, "reactions", ObjType::Map)?;
|
||||
tx.put_object(&comment_id, "replies", ObjType::List)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Copy, Clone, PartialOrd, PartialEq, Ord, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Timestamp {
|
||||
seconds: u64,
|
||||
}
|
||||
|
||||
impl Timestamp {
|
||||
pub fn new(seconds: u64) -> Self {
|
||||
Self { seconds }
|
||||
}
|
||||
|
||||
pub fn now() -> Self {
|
||||
#[allow(clippy::unwrap_used)] // Safe because Unix was already invented!
|
||||
let duration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
|
||||
|
||||
Self {
|
||||
seconds: duration.as_secs(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_secs(&self) -> u64 {
|
||||
self.seconds
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Add<u64> for Timestamp {
|
||||
type Output = Self;
|
||||
|
||||
fn add(self, rhs: u64) -> Self::Output {
|
||||
Self {
|
||||
seconds: self.seconds + rhs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Timestamp> for ScalarValue {
|
||||
fn from(ts: Timestamp) -> Self {
|
||||
ScalarValue::Timestamp(ts.seconds as i64)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FromValue<'a> for Timestamp {
|
||||
fn from_value(val: Value<'a>) -> Result<Self, ValueError> {
|
||||
if let Value::Scalar(scalar) = &val {
|
||||
if let ScalarValue::Timestamp(ts) = scalar.borrow() {
|
||||
return Ok(Self {
|
||||
seconds: *ts as u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(ValueError::InvalidValue(val.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub mod lookup {
|
||||
use super::{Author, Comment, HashMap, Reaction, Replies};
|
||||
use super::{Document, DocumentError};
|
||||
|
||||
pub fn comment(doc: Document, obj_id: &automerge::ObjId) -> Result<Comment<()>, DocumentError> {
|
||||
let author = doc.get(obj_id, "author").map(Author::new)?;
|
||||
let body = doc.get(obj_id, "body")?;
|
||||
let timestamp = doc.get(obj_id, "timestamp")?;
|
||||
let reactions: HashMap<Reaction, usize> = doc.map(obj_id, "reactions", |v| *v += 1)?;
|
||||
|
||||
Ok(Comment {
|
||||
author,
|
||||
body,
|
||||
reactions,
|
||||
replies: (),
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn thread(
|
||||
doc: Document,
|
||||
obj_id: &automerge::ObjId,
|
||||
) -> Result<Comment<Replies>, DocumentError> {
|
||||
let comment = self::comment(doc, obj_id)?;
|
||||
let replies = doc.list(obj_id, "replies", self::comment)?;
|
||||
|
||||
Ok(Comment {
|
||||
author: comment.author,
|
||||
body: comment.body,
|
||||
reactions: comment.reactions,
|
||||
replies,
|
||||
timestamp: comment.timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_color() {
|
||||
let c = Color::from_str("#ffccaa").unwrap();
|
||||
assert_eq!(c.to_string(), "#ffccaa".to_owned());
|
||||
assert_eq!(serde_json::to_string(&c).unwrap(), "\"#ffccaa\"".to_owned());
|
||||
assert_eq!(serde_json::from_str::<'_, Color>("\"#ffccaa\"").unwrap(), c);
|
||||
|
||||
let c = Color::from_str("#0000aa").unwrap();
|
||||
assert_eq!(c.to_string(), "#0000aa".to_owned());
|
||||
|
||||
let c = Color::from_str("#aa0000").unwrap();
|
||||
assert_eq!(c.to_string(), "#aa0000".to_owned());
|
||||
|
||||
let c = Color::from_str("#00aa00").unwrap();
|
||||
assert_eq!(c.to_string(), "#00aa00".to_owned());
|
||||
|
||||
Color::from_str("#aa00").unwrap_err();
|
||||
Color::from_str("#abc").unwrap_err();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue