Implement issues using radicle CRDTs
Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
parent
36ccab2d7a
commit
171b5a45f7
|
|
@ -7,9 +7,8 @@ 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::common::{Reaction, Tag};
|
||||
use radicle::cob::issue::{CloseReason, IssueId, Issues, Status};
|
||||
use radicle::storage::WriteStorage;
|
||||
|
||||
pub const HELP: Help = Help {
|
||||
|
|
@ -34,7 +33,7 @@ Options
|
|||
#[derive(serde::Deserialize, serde::Serialize, Debug)]
|
||||
pub struct Metadata {
|
||||
title: String,
|
||||
labels: Vec<Label>,
|
||||
labels: Vec<Tag>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
|
|
@ -60,7 +59,7 @@ pub enum Operation {
|
|||
},
|
||||
State {
|
||||
id: IssueId,
|
||||
state: State,
|
||||
state: Status,
|
||||
},
|
||||
Delete {
|
||||
id: IssueId,
|
||||
|
|
@ -87,7 +86,7 @@ impl Args for Options {
|
|||
let mut title: Option<String> = None;
|
||||
let mut reaction: Option<Reaction> = None;
|
||||
let mut description: Option<String> = None;
|
||||
let mut state: Option<State> = None;
|
||||
let mut state: Option<Status> = None;
|
||||
|
||||
while let Some(arg) = parser.next()? {
|
||||
match arg {
|
||||
|
|
@ -98,15 +97,15 @@ impl Args for Options {
|
|||
title = Some(parser.value()?.to_string_lossy().into());
|
||||
}
|
||||
Long("closed") if op == Some(OperationName::State) => {
|
||||
state = Some(State::Closed {
|
||||
state = Some(Status::Closed {
|
||||
reason: CloseReason::Other,
|
||||
});
|
||||
}
|
||||
Long("open") if op == Some(OperationName::State) => {
|
||||
state = Some(State::Open);
|
||||
state = Some(Status::Open);
|
||||
}
|
||||
Long("solved") if op == Some(OperationName::State) => {
|
||||
state = Some(State::Closed {
|
||||
state = Some(Status::Closed {
|
||||
reason: CloseReason::Solved,
|
||||
});
|
||||
}
|
||||
|
|
@ -170,23 +169,23 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
|||
let storage = &profile.storage;
|
||||
let (_, id) = radicle::rad::cwd()?;
|
||||
let repo = storage.repository(id)?;
|
||||
let cobs = Store::open(*signer.public_key(), &repo)?;
|
||||
let issues = cobs.issues();
|
||||
let mut issues = Issues::open(*signer.public_key(), &repo)?;
|
||||
|
||||
match options.op {
|
||||
Operation::Create {
|
||||
title: Some(title),
|
||||
description: Some(description),
|
||||
} => {
|
||||
issues.create(&title, &description, &[], &signer)?;
|
||||
issues.create(title, description, &[], &signer)?;
|
||||
}
|
||||
Operation::State { id, state } => {
|
||||
issues.lifecycle(&id, state, &signer)?;
|
||||
let mut issue = issues.get_mut(&id)?;
|
||||
issue.lifecycle(state, &signer)?;
|
||||
}
|
||||
Operation::React { id, reaction } => {
|
||||
if let Some(issue) = issues.get(&id)? {
|
||||
if let Ok(mut issue) = issues.get_mut(&id) {
|
||||
let comment_id = term::comment_select(&issue).unwrap();
|
||||
issues.react(&id, comment_id, reaction, &signer)?;
|
||||
issue.react(comment_id, reaction, &signer)?;
|
||||
}
|
||||
}
|
||||
Operation::Create { title, description } => {
|
||||
|
|
@ -234,12 +233,13 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
|||
}
|
||||
}
|
||||
Operation::List => {
|
||||
for (id, issue) in issues.all()? {
|
||||
for result in issues.all()? {
|
||||
let (id, issue) = result?;
|
||||
println!("{} {}", id, issue.title());
|
||||
}
|
||||
}
|
||||
Operation::Delete { id } => {
|
||||
issues.remove(&id, &signer)?;
|
||||
issues.remove(&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::thread::CommentId;
|
||||
use radicle::crypto::ssh::keystore::Passphrase;
|
||||
use radicle::crypto::Signer;
|
||||
use radicle::profile::env::RAD_PASSPHRASE;
|
||||
|
|
@ -378,19 +378,20 @@ where
|
|||
pub fn comment_select(issue: &Issue) -> Option<CommentId> {
|
||||
let selection = dialoguer::Select::with_theme(&theme())
|
||||
.with_prompt("Which comment do you want to react to?")
|
||||
.item(&issue.description().to_string())
|
||||
.item(issue.description().unwrap_or_default())
|
||||
.items(
|
||||
&issue
|
||||
.comments()
|
||||
.iter()
|
||||
.map(|p| p.body.clone())
|
||||
.map(|(_, i)| i.body.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.default(CommentId::root().into())
|
||||
.default(0)
|
||||
.interact_opt()
|
||||
.unwrap();
|
||||
|
||||
selection.map(CommentId::from)
|
||||
selection
|
||||
.and_then(|n| issue.comments().nth(n))
|
||||
.map(|(id, _)| *id)
|
||||
}
|
||||
|
||||
pub fn markdown(content: &str) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
pub mod doc;
|
||||
pub mod issue;
|
||||
pub mod label;
|
||||
pub mod patch;
|
||||
pub mod shared;
|
||||
|
|
|
|||
|
|
@ -1,682 +0,0 @@
|
|||
#![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");
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ impl TryFrom<Document<'_>> for Patch {
|
|||
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 labels: HashSet<Tag> = doc.keys(&obj_id, "labels")?;
|
||||
let revisions =
|
||||
NonEmpty::from_vec(revisions).ok_or(DocumentError::EmptyList("revisions"))?;
|
||||
let author: Author = Author::new(author);
|
||||
|
|
@ -81,7 +81,7 @@ impl<'a> PatchStore<'a> {
|
|||
target: MergeTarget,
|
||||
base: impl Into<git::Oid>,
|
||||
oid: impl Into<git::Oid>,
|
||||
labels: &[Label],
|
||||
labels: &[Tag],
|
||||
signer: &G,
|
||||
) -> Result<PatchId, Error> {
|
||||
let author = self.author();
|
||||
|
|
@ -464,7 +464,7 @@ mod events {
|
|||
revision: &Revision,
|
||||
target: MergeTarget,
|
||||
timestamp: Timestamp,
|
||||
labels: &[Label],
|
||||
labels: &[Tag],
|
||||
) -> Result<Contents, TransactionError> {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::cob;
|
|||
use crate::cob::automerge::doc::DocumentError;
|
||||
use crate::cob::automerge::shared::FromHistory;
|
||||
use crate::cob::automerge::transaction::TransactionError;
|
||||
use crate::cob::automerge::{issue, label, patch};
|
||||
use crate::cob::automerge::{label, patch};
|
||||
use crate::cob::common::Author;
|
||||
use crate::cob::CollaborativeObject;
|
||||
use crate::cob::{Contents, Create, HistoryType, ObjectId, TypeName, Update};
|
||||
|
|
@ -77,16 +77,6 @@ impl<'a> Store<'a, ()> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Return an issues store from this generic store.
|
||||
pub fn issues(&self) -> issue::IssueStore<'_> {
|
||||
issue::IssueStore::new(Store {
|
||||
whoami: self.whoami,
|
||||
project: self.project.clone(),
|
||||
raw: self.raw,
|
||||
witness: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return a labels store from this generic store.
|
||||
pub fn labels(&self) -> label::LabelStore<'_> {
|
||||
label::LabelStore::new(Store {
|
||||
|
|
|
|||
|
|
@ -95,21 +95,21 @@ impl FromStr for Reaction {
|
|||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum LabelError {
|
||||
#[error("invalid label name: `{0}`")]
|
||||
pub enum TagError {
|
||||
#[error("invalid tag name: `{0}`")]
|
||||
InvalidName(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Label(String);
|
||||
pub struct Tag(String);
|
||||
|
||||
impl Label {
|
||||
pub fn new(name: impl Into<String>) -> Result<Self, LabelError> {
|
||||
impl Tag {
|
||||
pub fn new(name: impl Into<String>) -> Result<Self, TagError> {
|
||||
let name = name.into();
|
||||
|
||||
if name.chars().any(|c| c.is_whitespace()) || name.is_empty() {
|
||||
return Err(LabelError::InvalidName(name));
|
||||
return Err(TagError::InvalidName(name));
|
||||
}
|
||||
Ok(Self(name))
|
||||
}
|
||||
|
|
@ -119,16 +119,16 @@ impl Label {
|
|||
}
|
||||
}
|
||||
|
||||
impl FromStr for Label {
|
||||
type Err = LabelError;
|
||||
impl FromStr for Tag {
|
||||
type Err = TagError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Label> for String {
|
||||
fn from(Label(name): Label) -> Self {
|
||||
impl From<Tag> for String {
|
||||
fn from(Tag(name): Tag) -> Self {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::{ControlFlow, Deref};
|
||||
use std::str::FromStr;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use radicle_crdt as crdt;
|
||||
use radicle_crdt::{ChangeId, LClock, LWWReg, Max, Semilattice};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::cob::common::*;
|
||||
use crate::cob::{ObjectId, Timestamp, TypeName};
|
||||
use crate::cob::common::{Author, Reaction, Tag};
|
||||
use crate::cob::thread;
|
||||
use crate::cob::thread::{CommentId, Thread};
|
||||
use crate::cob::{store, ObjectId, TypeName};
|
||||
use crate::crypto::{PublicKey, Signer};
|
||||
use crate::storage::git as storage;
|
||||
|
||||
pub type Change = crdt::Change<Action>;
|
||||
|
||||
/// Type name of an issue.
|
||||
pub static TYPENAME: Lazy<TypeName> =
|
||||
|
|
@ -14,75 +23,589 @@ pub static TYPENAME: Lazy<TypeName> =
|
|||
/// Identifier for an issue.
|
||||
pub type IssueId = ObjectId;
|
||||
|
||||
/// Error updating or creating issues.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("apply failed")]
|
||||
Apply,
|
||||
#[error("store: {0}")]
|
||||
Store(#[from] store::Error),
|
||||
}
|
||||
|
||||
/// Reason why an issue was closed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CloseReason {
|
||||
Solved,
|
||||
Other,
|
||||
Solved,
|
||||
}
|
||||
|
||||
/// Issue state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase", tag = "status")]
|
||||
pub enum State {
|
||||
/// The issue is open.
|
||||
Open,
|
||||
pub enum Status {
|
||||
/// The issue is closed.
|
||||
Closed { reason: CloseReason },
|
||||
/// The issue is open.
|
||||
#[default]
|
||||
Open,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn lifecycle_message(self) -> String {
|
||||
impl std::fmt::Display for Status {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
State::Open => "Open issue".to_owned(),
|
||||
State::Closed { .. } => "Close issue".to_owned(),
|
||||
Self::Closed { .. } => write!(f, "closed"),
|
||||
Self::Open { .. } => write!(f, "open"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An issue or "ticket".
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
impl Status {
|
||||
pub fn lifecycle_message(self) -> String {
|
||||
match self {
|
||||
Status::Open => "Open issue".to_owned(),
|
||||
Status::Closed { .. } => "Close issue".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue state. Accumulates [`Action`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Issue {
|
||||
pub author: Author,
|
||||
pub title: String,
|
||||
pub state: State,
|
||||
pub comment: Comment,
|
||||
pub discussion: Discussion,
|
||||
pub labels: HashSet<Label>,
|
||||
pub timestamp: Timestamp,
|
||||
// TODO(cloudhead): Title should bias towards shorter strings.
|
||||
title: LWWReg<Max<String>, LClock>,
|
||||
status: LWWReg<Max<Status>, LClock>,
|
||||
thread: Thread,
|
||||
clock: LClock,
|
||||
}
|
||||
|
||||
impl Semilattice for Issue {
|
||||
fn merge(&mut self, other: Self) {
|
||||
self.title.merge(other.title);
|
||||
self.status.merge(other.status);
|
||||
self.thread.merge(other.thread);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Issue {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: Max::from(String::default()).into(),
|
||||
status: Max::from(Status::default()).into(),
|
||||
thread: Thread::default(),
|
||||
clock: LClock::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl store::FromHistory for Issue {
|
||||
fn type_name() -> &'static TypeName {
|
||||
&*TYPENAME
|
||||
}
|
||||
|
||||
fn from_history(history: &radicle_cob::History) -> Result<Self, store::Error> {
|
||||
Ok(history.traverse(Self::default(), |mut acc, entry| {
|
||||
if let Ok(change) = Change::decode(entry.contents()) {
|
||||
if let Err(err) = acc.apply(change) {
|
||||
log::warn!("Error applying change to issue state: {err}");
|
||||
return ControlFlow::Break(acc);
|
||||
}
|
||||
} else {
|
||||
return ControlFlow::Break(acc);
|
||||
}
|
||||
ControlFlow::Continue(acc)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Issue {
|
||||
pub fn author(&self) -> &Author {
|
||||
&self.author
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
self.title.get().as_str()
|
||||
}
|
||||
|
||||
pub fn state(&self) -> State {
|
||||
self.state
|
||||
pub fn status(&self) -> &Status {
|
||||
self.status.get()
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &str {
|
||||
&self.comment.body
|
||||
pub fn tags(&self) -> impl Iterator<Item = &Tag> {
|
||||
self.thread.tags()
|
||||
}
|
||||
|
||||
pub fn reactions(&self) -> &HashMap<Reaction, usize> {
|
||||
&self.comment.reactions
|
||||
pub fn author(&self) -> Option<Author> {
|
||||
self.thread
|
||||
.comments()
|
||||
.next()
|
||||
.map(|((_, pk), _)| Author::new(*pk))
|
||||
}
|
||||
|
||||
pub fn comments(&self) -> &[Comment<Replies>] {
|
||||
&self.discussion
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.thread.comments().next().map(|(_, c)| c.body.as_str())
|
||||
}
|
||||
|
||||
pub fn labels(&self) -> &HashSet<Label> {
|
||||
&self.labels
|
||||
pub fn comments(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment)> {
|
||||
self.thread.comments().map(|(id, comment)| (id, comment))
|
||||
}
|
||||
|
||||
pub fn timestamp(&self) -> Timestamp {
|
||||
self.timestamp
|
||||
pub fn clock(&self) -> &LClock {
|
||||
&self.clock
|
||||
}
|
||||
|
||||
pub fn timeline(&self) -> Vec<Action> {
|
||||
todo!();
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, change: Change) -> Result<(), Error> {
|
||||
match change.action {
|
||||
Action::Title { title } => {
|
||||
self.title.set(title, change.clock);
|
||||
}
|
||||
Action::Lifecycle { status } => {
|
||||
self.status.set(status, change.clock);
|
||||
}
|
||||
Action::Thread { action } => {
|
||||
self.thread.apply([crdt::Change {
|
||||
action,
|
||||
author: change.author,
|
||||
clock: change.clock,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
self.clock.merge(change.clock);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Issue {
|
||||
type Target = Thread;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.thread
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IssueMut<'a, 'g> {
|
||||
id: ObjectId,
|
||||
clock: LClock,
|
||||
issue: Issue,
|
||||
store: &'g mut Issues<'a>,
|
||||
}
|
||||
|
||||
impl<'a, 'g> IssueMut<'a, 'g> {
|
||||
/// Get the internal logical clock.
|
||||
pub fn clock(&self) -> &LClock {
|
||||
&self.clock
|
||||
}
|
||||
|
||||
/// Lifecycle an issue.
|
||||
pub fn lifecycle<G: Signer>(&mut self, status: Status, signer: &G) -> Result<ChangeId, Error> {
|
||||
let clock = self.clock.tick();
|
||||
let change = Change {
|
||||
action: Action::Lifecycle { status },
|
||||
author: *signer.public_key(),
|
||||
clock,
|
||||
};
|
||||
self.apply("Lifecycle", change, signer)
|
||||
}
|
||||
|
||||
/// Comment on an issue.
|
||||
pub fn comment<G: Signer, S: Into<String>>(
|
||||
&mut self,
|
||||
body: S,
|
||||
signer: &G,
|
||||
) -> Result<CommentId, Error> {
|
||||
let author = *signer.public_key();
|
||||
let clock = self.clock.tick();
|
||||
let body = body.into();
|
||||
let comment = thread::Comment::new(body, None);
|
||||
let change = Change {
|
||||
action: Action::from(thread::Action::Comment { comment }),
|
||||
author,
|
||||
clock,
|
||||
};
|
||||
self.apply("Comment", change, signer)
|
||||
}
|
||||
|
||||
/// Tag an issue.
|
||||
pub fn tag<G: Signer>(
|
||||
&mut self,
|
||||
tags: impl IntoIterator<Item = Tag>,
|
||||
signer: &G,
|
||||
) -> Result<ChangeId, Error> {
|
||||
let author = *signer.public_key();
|
||||
let clock = self.clock.tick();
|
||||
let tags = tags.into_iter().collect::<Vec<_>>();
|
||||
let change = Change {
|
||||
author,
|
||||
action: Action::Thread {
|
||||
action: thread::Action::Tag { tags },
|
||||
},
|
||||
clock,
|
||||
};
|
||||
self.apply("Tag", change, signer)
|
||||
}
|
||||
|
||||
/// Reply to on an issue comment.
|
||||
pub fn reply<G: Signer, S: Into<String>>(
|
||||
&mut self,
|
||||
parent: CommentId,
|
||||
body: S,
|
||||
signer: &G,
|
||||
) -> Result<ChangeId, Error> {
|
||||
let author = *signer.public_key();
|
||||
let clock = self.clock.tick();
|
||||
let body = body.into();
|
||||
|
||||
assert!(self.thread.comment(&parent).is_some());
|
||||
|
||||
let comment = thread::Comment::new(body, Some(parent));
|
||||
let change = Change {
|
||||
action: Action::from(thread::Action::Comment { comment }),
|
||||
author,
|
||||
clock,
|
||||
};
|
||||
self.apply("Reply", change, signer)
|
||||
}
|
||||
|
||||
/// React to an issue comment.
|
||||
pub fn react<G: Signer>(
|
||||
&mut self,
|
||||
to: CommentId,
|
||||
reaction: Reaction,
|
||||
signer: &G,
|
||||
) -> Result<ChangeId, Error> {
|
||||
let author = *signer.public_key();
|
||||
let clock = self.clock.tick();
|
||||
let change = Change {
|
||||
action: Action::Thread {
|
||||
action: thread::Action::React {
|
||||
to,
|
||||
reaction,
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
author,
|
||||
clock,
|
||||
};
|
||||
self.apply("React", change, signer)
|
||||
}
|
||||
|
||||
/// Apply a change to the issue.
|
||||
pub fn apply<G: Signer>(
|
||||
&mut self,
|
||||
msg: &'static str,
|
||||
change: Change,
|
||||
signer: &G,
|
||||
) -> Result<ChangeId, Error> {
|
||||
let id = change.id();
|
||||
|
||||
self.issue.apply(change.clone())?;
|
||||
self.store
|
||||
.update(self.id, msg, change, signer)
|
||||
.map_err(|e| Error::Store(store::Error::from(e)))?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'g> Deref for IssueMut<'a, 'g> {
|
||||
type Target = Issue;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.issue
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Issues<'a> {
|
||||
raw: store::Store<'a, Issue>,
|
||||
}
|
||||
|
||||
impl<'a> Deref for Issues<'a> {
|
||||
type Target = store::Store<'a, Issue>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.raw
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Issues<'a> {
|
||||
/// Open an issues store.
|
||||
pub fn open(
|
||||
whoami: PublicKey,
|
||||
repository: &'a storage::Repository,
|
||||
) -> Result<Self, store::Error> {
|
||||
let raw = store::Store::open(whoami, repository)?;
|
||||
|
||||
Ok(Self { raw })
|
||||
}
|
||||
|
||||
/// Get an issue.
|
||||
pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, store::Error> {
|
||||
self.raw.get(id)
|
||||
}
|
||||
|
||||
/// Get an issue mutably.
|
||||
pub fn get_mut<'g>(&'g mut self, id: &ObjectId) -> Result<IssueMut<'a, 'g>, store::Error> {
|
||||
let issue = self
|
||||
.raw
|
||||
.get(id)?
|
||||
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
|
||||
|
||||
Ok(IssueMut {
|
||||
id: *id,
|
||||
clock: issue.clock,
|
||||
issue,
|
||||
store: self,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new issue.
|
||||
pub fn create<'g, G: Signer>(
|
||||
&'g mut self,
|
||||
title: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
tags: &[Tag],
|
||||
signer: &G,
|
||||
) -> Result<IssueMut<'a, 'g>, Error> {
|
||||
let title = title.into();
|
||||
let description = description.into();
|
||||
let change = Change {
|
||||
author: self.author().id,
|
||||
action: Action::Title { title },
|
||||
clock: LClock::default(),
|
||||
};
|
||||
let (id, issue): (_, Issue) = self.raw.create("Create issue", change, signer)?;
|
||||
let mut issue = IssueMut {
|
||||
id,
|
||||
clock: issue.clock,
|
||||
issue,
|
||||
store: self,
|
||||
};
|
||||
|
||||
issue.comment(description, signer)?;
|
||||
issue.tag(tags.to_owned(), signer)?;
|
||||
|
||||
Ok(issue)
|
||||
}
|
||||
|
||||
/// Remove an issue.
|
||||
pub fn remove(&self, id: &ObjectId) -> Result<(), store::Error> {
|
||||
self.raw.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue operation.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub enum Action {
|
||||
Title { title: String },
|
||||
Lifecycle { status: Status },
|
||||
Thread { action: thread::Action },
|
||||
}
|
||||
|
||||
impl From<thread::Action> for Action {
|
||||
fn from(action: thread::Action) -> Self {
|
||||
Self::Thread { action }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::cob::Reaction;
|
||||
use crate::test;
|
||||
|
||||
#[test]
|
||||
fn test_ordering() {
|
||||
assert!(CloseReason::Solved > CloseReason::Other);
|
||||
assert!(
|
||||
Status::Open
|
||||
> Status::Closed {
|
||||
reason: CloseReason::Solved
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_create_and_get() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
|
||||
let created = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
let (id, created) = (created.id, created.issue);
|
||||
let issue = issues.get(&id).unwrap().unwrap();
|
||||
|
||||
assert_eq!(issue, created);
|
||||
assert_eq!(issue.title(), "My first issue");
|
||||
assert_eq!(issue.author(), Some(issues.author()));
|
||||
assert_eq!(issue.description(), Some("Blah blah blah."));
|
||||
assert_eq!(issue.comments().count(), 1);
|
||||
assert_eq!(issue.status(), &Status::Open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_create_and_change_state() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
|
||||
let mut issue = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
issue
|
||||
.lifecycle(
|
||||
Status::Closed {
|
||||
reason: CloseReason::Other,
|
||||
},
|
||||
&signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let id = issue.id;
|
||||
let mut issue = issues.get_mut(&id).unwrap();
|
||||
assert_eq!(
|
||||
*issue.status(),
|
||||
Status::Closed {
|
||||
reason: CloseReason::Other
|
||||
}
|
||||
);
|
||||
|
||||
issue.lifecycle(Status::Open, &signer).unwrap();
|
||||
let issue = issues.get(&id).unwrap().unwrap();
|
||||
assert_eq!(*issue.status(), Status::Open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_react() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
|
||||
let mut issue = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
let comment = (LClock::default(), *signer.public_key());
|
||||
let reaction = Reaction::new('🥳').unwrap();
|
||||
issue.react(comment, reaction, &signer).unwrap();
|
||||
|
||||
let id = issue.id;
|
||||
let issue = issues.get(&id).unwrap().unwrap();
|
||||
let (_, r) = issue.reactions(&comment).next().unwrap();
|
||||
|
||||
assert_eq!(r, &reaction);
|
||||
|
||||
// TODO: Test multiple reactions from same author and different authors
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_reply() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
|
||||
let mut issue = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
let comment = issue.comment("Ho ho ho.", &signer).unwrap();
|
||||
|
||||
issue.reply(comment, "Hi hi hi.", &signer).unwrap();
|
||||
issue.reply(comment, "Ha ha ha.", &signer).unwrap();
|
||||
|
||||
let id = issue.id;
|
||||
let issue = issues.get(&id).unwrap().unwrap();
|
||||
let (_, reply1) = &issue.replies(&comment).nth(0).unwrap();
|
||||
let (_, reply2) = &issue.replies(&comment).nth(1).unwrap();
|
||||
|
||||
assert_eq!(reply1.body, "Hi hi hi.");
|
||||
assert_eq!(reply2.body, "Ha ha ha.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_tag() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
|
||||
let mut issue = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
let bug_tag = Tag::new("bug").unwrap();
|
||||
let wontfix_tag = Tag::new("wontfix").unwrap();
|
||||
|
||||
issue.tag([bug_tag.clone()], &signer).unwrap();
|
||||
issue.tag([wontfix_tag.clone()], &signer).unwrap();
|
||||
|
||||
let id = issue.id;
|
||||
let issue = issues.get(&id).unwrap().unwrap();
|
||||
let tags = issue.tags().cloned().collect::<Vec<_>>();
|
||||
|
||||
assert!(tags.contains(&bug_tag));
|
||||
assert!(tags.contains(&wontfix_tag));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_comment() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, signer, project) = test::setup::context(&tmp);
|
||||
let author = *signer.public_key();
|
||||
let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
|
||||
let mut issue = issues
|
||||
.create("My first issue", "Blah blah blah.", &[], &signer)
|
||||
.unwrap();
|
||||
|
||||
issue.comment("Ho ho ho.", &signer).unwrap();
|
||||
issue.comment("Ha ha ha.", &signer).unwrap();
|
||||
|
||||
let id = issue.id;
|
||||
let issue = issues.get(&id).unwrap().unwrap();
|
||||
let ((_, a0), c0) = &issue.comments().nth(0).unwrap();
|
||||
let ((_, a1), c1) = &issue.comments().nth(1).unwrap();
|
||||
let ((_, a2), c2) = &issue.comments().nth(2).unwrap();
|
||||
|
||||
assert_eq!(&c0.body, "Blah blah blah.");
|
||||
assert_eq!(a0, &author);
|
||||
assert_eq!(&c1.body, "Ho ho ho.");
|
||||
assert_eq!(a1, &author);
|
||||
assert_eq!(&c2.body, "Ha ha ha.");
|
||||
assert_eq!(a2, &author);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_state_serde() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(Status::Open).unwrap(),
|
||||
serde_json::json!({ "status": "open" })
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(Status::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 mut issues = Issues::open(*signer.public_key(), &project).unwrap();
|
||||
|
||||
issues.create("First", "Blah", &[], &signer).unwrap();
|
||||
issues.create("Second", "Blah", &[], &signer).unwrap();
|
||||
issues.create("Third", "Blah", &[], &signer).unwrap();
|
||||
|
||||
let issues = issues
|
||||
.all()
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(issues.len(), 3);
|
||||
|
||||
issues.iter().find(|(_, i)| i.title() == "First").unwrap();
|
||||
issues.iter().find(|(_, i)| i.title() == "Second").unwrap();
|
||||
issues.iter().find(|(_, i)| i.title() == "Third").unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ where
|
|||
/// Target this patch is meant to be merged in.
|
||||
pub target: MergeTarget,
|
||||
/// Labels associated with the patch.
|
||||
pub labels: HashSet<Label>,
|
||||
pub labels: HashSet<Tag>,
|
||||
/// List of patch revisions. The initial changeset is part of the
|
||||
/// first revision.
|
||||
pub revisions: NonEmpty<Revision<T>>,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
#![allow(clippy::large_enum_variant)]
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use radicle_crdt::Change;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cob;
|
||||
use crate::cob::common::Author;
|
||||
use crate::cob::CollaborativeObject;
|
||||
use crate::cob::{Contents, Create, History, HistoryType, ObjectId, TypeName, Update};
|
||||
use crate::cob::{Create, History, HistoryType, ObjectId, TypeName, Update};
|
||||
use crate::crypto::PublicKey;
|
||||
use crate::git;
|
||||
use crate::identity::project;
|
||||
|
|
@ -32,7 +35,7 @@ pub enum Error {
|
|||
Retrieve(#[from] cob::error::Retrieve),
|
||||
#[error(transparent)]
|
||||
Identity(#[from] project::IdentityError),
|
||||
#[error("object `{1}`of type `{0}` was not found")]
|
||||
#[error("object `{1}` of type `{0}` was not found")]
|
||||
NotFound(TypeName, ObjectId),
|
||||
}
|
||||
|
||||
|
|
@ -62,9 +65,7 @@ impl<'a, T> Store<'a, T> {
|
|||
witness: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> Store<'a, T> {
|
||||
/// Get this store's author.
|
||||
pub fn author(&self) -> Author {
|
||||
Author::new(self.whoami)
|
||||
|
|
@ -78,11 +79,11 @@ impl<'a, T> Store<'a, T> {
|
|||
|
||||
impl<'a, T: FromHistory> Store<'a, T> {
|
||||
/// Update an object.
|
||||
pub fn update<G: Signer>(
|
||||
pub fn update<A: Serialize, G: Signer>(
|
||||
&self,
|
||||
object_id: ObjectId,
|
||||
message: &'static str,
|
||||
changes: Contents,
|
||||
change: Change<A>,
|
||||
signer: &G,
|
||||
) -> Result<CollaborativeObject, cob::error::Update> {
|
||||
cob::update(
|
||||
|
|
@ -95,19 +96,19 @@ impl<'a, T: FromHistory> Store<'a, T> {
|
|||
history_type: HistoryType::default(),
|
||||
typename: T::type_name().clone(),
|
||||
message: message.to_owned(),
|
||||
changes,
|
||||
changes: change.encode(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Create an object.
|
||||
pub fn create<G: Signer>(
|
||||
pub fn create<A: Serialize, G: Signer>(
|
||||
&self,
|
||||
message: &'static str,
|
||||
contents: Contents,
|
||||
change: Change<A>,
|
||||
signer: &G,
|
||||
) -> Result<CollaborativeObject, cob::error::Create> {
|
||||
cob::create(
|
||||
) -> Result<(ObjectId, T), Error> {
|
||||
let cob = cob::create(
|
||||
self.raw,
|
||||
signer,
|
||||
&self.project,
|
||||
|
|
@ -116,9 +117,12 @@ impl<'a, T: FromHistory> Store<'a, T> {
|
|||
history_type: HistoryType::default(),
|
||||
typename: T::type_name().clone(),
|
||||
message: message.to_owned(),
|
||||
contents,
|
||||
contents: change.encode(),
|
||||
},
|
||||
)
|
||||
)?;
|
||||
let object = T::from_history(cob.history())?;
|
||||
|
||||
Ok((*cob.id(), object))
|
||||
}
|
||||
|
||||
/// Get an object.
|
||||
|
|
@ -126,24 +130,24 @@ impl<'a, T: FromHistory> Store<'a, T> {
|
|||
let cob = cob::get(self.raw, T::type_name(), id)?;
|
||||
|
||||
if let Some(cob) = cob {
|
||||
let history = cob.history();
|
||||
let obj = T::from_history(history)?;
|
||||
|
||||
let obj = T::from_history(cob.history())?;
|
||||
Ok(Some(obj))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// List objects.
|
||||
pub fn list(&self) -> Result<Vec<(ObjectId, T)>, Error> {
|
||||
/// Return all objects.
|
||||
pub fn all(&self) -> Result<impl Iterator<Item = Result<(ObjectId, T), Error>>, Error> {
|
||||
let raw = cob::list(self.raw, T::type_name())?;
|
||||
|
||||
raw.into_iter()
|
||||
.map(|o| {
|
||||
let obj = T::from_history(o.history())?;
|
||||
Ok::<_, Error>((*o.id(), obj))
|
||||
})
|
||||
.collect()
|
||||
Ok(raw.into_iter().map(|o| {
|
||||
let obj = T::from_history(o.history())?;
|
||||
Ok((*o.id(), obj))
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn remove(&self, _id: &ObjectId) -> Result<(), Error> {
|
||||
todo!();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use once_cell::sync::Lazy;
|
|||
use radicle_crdt as crdt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cob::common::Reaction;
|
||||
use crate::cob::common::{Reaction, Tag};
|
||||
use crate::cob::store;
|
||||
use crate::cob::{History, TypeName};
|
||||
use crate::crypto::{PublicKey, Signer};
|
||||
|
|
@ -22,18 +22,18 @@ use crdt::{Change, ChangeId, Semilattice};
|
|||
pub static TYPENAME: Lazy<TypeName> =
|
||||
Lazy::new(|| FromStr::from_str("xyz.radicle.thread").expect("type name is valid"));
|
||||
|
||||
/// Identifies a tag.
|
||||
pub type TagId = String;
|
||||
/// Alias for `Author`.
|
||||
pub type ActorId = PublicKey;
|
||||
/// Identifies a comment.
|
||||
pub type CommentId = ChangeId;
|
||||
|
||||
/// A comment on a discussion thread.
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Comment {
|
||||
/// The comment body.
|
||||
body: String,
|
||||
pub body: String,
|
||||
/// Thread or comment this is a reply to.
|
||||
reply_to: Option<ChangeId>,
|
||||
pub reply_to: Option<ChangeId>,
|
||||
}
|
||||
|
||||
impl Comment {
|
||||
|
|
@ -56,10 +56,10 @@ pub enum Action {
|
|||
Comment { comment: Comment },
|
||||
/// Redact a change. Not all changes can be redacted.
|
||||
Redact { id: ChangeId },
|
||||
/// Add a tag to the thread.
|
||||
Tag { tag: TagId },
|
||||
/// Remove a tag from the thread.
|
||||
Untag { tag: TagId },
|
||||
/// Add tags to the thread.
|
||||
Tag { tags: Vec<Tag> },
|
||||
/// Remove tags from the thread.
|
||||
Untag { tags: Vec<Tag> },
|
||||
/// React to a change.
|
||||
React {
|
||||
to: ChangeId,
|
||||
|
|
@ -72,11 +72,11 @@ pub enum Action {
|
|||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Thread {
|
||||
/// The comments under the thread.
|
||||
comments: BTreeMap<ChangeId, Redactable<Comment>>,
|
||||
comments: BTreeMap<CommentId, Redactable<Comment>>,
|
||||
/// Associated tags.
|
||||
tags: BTreeMap<TagId, LWWReg<bool, LClock>>,
|
||||
tags: BTreeMap<Tag, LWWReg<bool, LClock>>,
|
||||
/// Reactions to changes.
|
||||
reactions: BTreeMap<ChangeId, LWWSet<(ActorId, Reaction), LClock>>,
|
||||
reactions: BTreeMap<CommentId, LWWSet<(ActorId, Reaction), LClock>>,
|
||||
}
|
||||
|
||||
impl store::FromHistory for Thread {
|
||||
|
|
@ -105,7 +105,7 @@ impl Semilattice for Thread {
|
|||
}
|
||||
|
||||
impl Deref for Thread {
|
||||
type Target = BTreeMap<ChangeId, Redactable<Comment>>;
|
||||
type Target = BTreeMap<CommentId, Redactable<Comment>>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.comments
|
||||
|
|
@ -117,6 +117,39 @@ impl Thread {
|
|||
self.comments.clear();
|
||||
}
|
||||
|
||||
pub fn comment(&self, id: &CommentId) -> Option<&Comment> {
|
||||
if let Some(Redactable::Present(comment)) = self.comments.get(id) {
|
||||
Some(comment)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replies<'a>(
|
||||
&'a self,
|
||||
to: &'a CommentId,
|
||||
) -> impl Iterator<Item = (&CommentId, &Comment)> {
|
||||
self.comments().filter_map(move |(id, c)| {
|
||||
if let Some(parent) = &c.reply_to {
|
||||
if parent == to {
|
||||
return Some((id, c));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reactions<'a>(
|
||||
&'a self,
|
||||
to: &'a CommentId,
|
||||
) -> impl Iterator<Item = (&ActorId, &Reaction)> {
|
||||
self.reactions
|
||||
.get(to)
|
||||
.into_iter()
|
||||
.flat_map(move |rs| rs.iter())
|
||||
.map(|(a, r)| (a, r))
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, changes: impl IntoIterator<Item = Change<Action>>) {
|
||||
for change in changes.into_iter() {
|
||||
let id = change.id();
|
||||
|
|
@ -140,17 +173,21 @@ impl Thread {
|
|||
.and_modify(|e| e.merge(Redactable::Redacted))
|
||||
.or_insert(Redactable::Redacted);
|
||||
}
|
||||
Action::Tag { tag } => {
|
||||
self.tags
|
||||
.entry(tag)
|
||||
.and_modify(|r| r.set(true, change.clock))
|
||||
.or_insert_with(|| LWWReg::new(true, change.clock));
|
||||
Action::Tag { tags } => {
|
||||
for tag in tags {
|
||||
self.tags
|
||||
.entry(tag)
|
||||
.and_modify(|r| r.set(true, change.clock))
|
||||
.or_insert_with(|| LWWReg::new(true, change.clock));
|
||||
}
|
||||
}
|
||||
Action::Untag { tag } => {
|
||||
self.tags
|
||||
.entry(tag)
|
||||
.and_modify(|r| r.set(false, change.clock))
|
||||
.or_insert_with(|| LWWReg::new(false, change.clock));
|
||||
Action::Untag { tags } => {
|
||||
for tag in tags {
|
||||
self.tags
|
||||
.entry(tag)
|
||||
.and_modify(|r| r.set(false, change.clock))
|
||||
.or_insert_with(|| LWWReg::new(false, change.clock));
|
||||
}
|
||||
}
|
||||
Action::React {
|
||||
to,
|
||||
|
|
@ -180,7 +217,7 @@ impl Thread {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn comments(&self) -> impl Iterator<Item = (&ChangeId, &Comment)> + '_ {
|
||||
pub fn comments(&self) -> impl Iterator<Item = (&CommentId, &Comment)> + '_ {
|
||||
self.comments.iter().filter_map(|(id, comment)| {
|
||||
if let Redactable::Present(c) = comment {
|
||||
Some((id, c))
|
||||
|
|
@ -190,7 +227,7 @@ impl Thread {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn tags(&self) -> impl Iterator<Item = &TagId> + '_ {
|
||||
pub fn tags(&self) -> impl Iterator<Item = &Tag> + '_ {
|
||||
self.tags
|
||||
.iter()
|
||||
.filter_map(|(tag, r)| if *r.get() { Some(tag) } else { None })
|
||||
|
|
@ -230,13 +267,13 @@ impl<G: Signer> Actor<G> {
|
|||
}
|
||||
|
||||
/// Add a tag.
|
||||
pub fn tag(&mut self, tag: TagId) -> Change<Action> {
|
||||
self.change(Action::Tag { tag })
|
||||
pub fn tag(&mut self, tag: Tag) -> Change<Action> {
|
||||
self.change(Action::Tag { tags: vec![tag] })
|
||||
}
|
||||
|
||||
/// Remove a tag.
|
||||
pub fn untag(&mut self, tag: TagId) -> Change<Action> {
|
||||
self.change(Action::Untag { tag })
|
||||
pub fn untag(&mut self, tag: Tag) -> Change<Action> {
|
||||
self.change(Action::Untag { tags: vec![tag] })
|
||||
}
|
||||
|
||||
/// Create a new redaction.
|
||||
|
|
@ -294,7 +331,7 @@ mod tests {
|
|||
fn arbitrary(g: &mut quickcheck::Gen) -> Self {
|
||||
let rng = fastrand::Rng::with_seed(u64::arbitrary(g));
|
||||
let gen =
|
||||
WeightedGenerator::<Action, (Vec<TagId>, Vec<Change<Action>>)>::new(rng.clone())
|
||||
WeightedGenerator::<Action, (Vec<Tag>, Vec<Change<Action>>)>::new(rng.clone())
|
||||
.variant(2, |_, rng| {
|
||||
Some(Action::Comment {
|
||||
comment: Comment {
|
||||
|
|
@ -327,19 +364,20 @@ mod tests {
|
|||
let tag = iter::repeat_with(|| rng.alphabetic())
|
||||
.take(8)
|
||||
.collect::<String>();
|
||||
let tag = Tag::new(tag).unwrap();
|
||||
tags.push(tag.clone());
|
||||
tag
|
||||
} else {
|
||||
tags[rng.usize(..tags.len())].clone()
|
||||
};
|
||||
Some(Action::Tag { tag })
|
||||
Some(Action::Tag { tags: vec![tag] })
|
||||
})
|
||||
.variant(2, |(tags, _), rng| {
|
||||
if tags.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let tag = tags[rng.usize(..tags.len())].clone();
|
||||
Some(Action::Untag { tag })
|
||||
Some(Action::Untag { tags: vec![tag] })
|
||||
});
|
||||
|
||||
let mut changes = Vec::new();
|
||||
|
|
@ -381,14 +419,12 @@ mod tests {
|
|||
let mut expected = Thread::default();
|
||||
expected.apply([a1.clone(), a2.clone()]);
|
||||
|
||||
let created = store
|
||||
.create("Thread created", a1.encode(), &alice.signer)
|
||||
.unwrap();
|
||||
let (id, _) = store.create("Thread created", a1, &alice.signer).unwrap();
|
||||
store
|
||||
.update(*created.id(), "Thread updated", a2.encode(), &alice.signer)
|
||||
.update(id, "Thread updated", a2, &alice.signer)
|
||||
.unwrap();
|
||||
|
||||
let actual = store.get(created.id()).unwrap().unwrap();
|
||||
let actual = store.get(&id).unwrap().unwrap();
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#![allow(clippy::match_like_matches_macro)]
|
||||
#![allow(clippy::explicit_auto_deref)] // TODO: This can be removed when the clippy bugs are fixed
|
||||
#![allow(clippy::iter_nth_zero)]
|
||||
#![cfg_attr(not(test), warn(clippy::unwrap_used))]
|
||||
|
||||
pub extern crate radicle_crypto as crypto;
|
||||
|
|
|
|||
Loading…
Reference in New Issue