Start adding support for replies in COBs

This makes a change to comments, such that all comments require a
parent, except the "root" comment, which is created with the COB.

Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
Alexis Sellier 2022-12-13 20:21:09 +01:00
parent ac3ee5f709
commit b32ae984ce
No known key found for this signature in database
3 changed files with 181 additions and 118 deletions

View File

@ -209,14 +209,22 @@ impl store::Transaction<Issue> {
self.push(Action::Lifecycle { state })
}
/// Comment on an issue.
pub fn comment<S: ToString>(&mut self, body: S) -> CommentId {
/// Create the issue thread.
pub fn thread<S: ToString>(&mut self, body: S) -> CommentId {
self.push(Action::from(thread::Action::Comment {
body: body.to_string(),
reply_to: None,
}))
}
/// Comment on an issue.
pub fn comment<S: ToString>(&mut self, body: S, reply_to: CommentId) -> CommentId {
self.push(Action::from(thread::Action::Comment {
body: body.to_string(),
reply_to: Some(reply_to),
}))
}
/// Tag an issue.
pub fn tag(
&mut self,
@ -229,16 +237,6 @@ impl store::Transaction<Issue> {
self.push(Action::Tag { add, remove })
}
/// Reply to on an issue comment.
pub fn reply<S: ToString>(&mut self, parent: CommentId, body: S) -> OpId {
let body = body.to_string();
self.push(Action::from(thread::Action::Comment {
body,
reply_to: Some(parent),
}))
}
/// React to an issue comment.
pub fn react(&mut self, to: CommentId, reaction: Reaction) -> OpId {
self.push(Action::Thread {
@ -269,13 +267,24 @@ impl<'a, 'g> IssueMut<'a, 'g> {
self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state))
}
/// Comment on an issue.
pub fn comment<G: Signer, S: ToString>(
/// Create the issue thread.
pub fn thread<G: Signer, S: ToString>(
&mut self,
body: S,
signer: &G,
) -> Result<CommentId, Error> {
self.transaction("Comment", signer, |tx| tx.comment(body))
self.transaction("Create thread", signer, |tx| tx.thread(body))
}
/// Comment on an issue.
pub fn comment<G: Signer, S: ToString>(
&mut self,
body: S,
reply_to: CommentId,
signer: &G,
) -> Result<CommentId, Error> {
assert!(self.thread.comment(&reply_to).is_some());
self.transaction("Comment", signer, |tx| tx.comment(body, reply_to))
}
/// Tag an issue.
@ -288,17 +297,6 @@ impl<'a, 'g> IssueMut<'a, 'g> {
self.transaction("Tag", signer, |tx| tx.tag(add, remove))
}
/// Reply to on an issue comment.
pub fn reply<G: Signer, S: ToString>(
&mut self,
parent: CommentId,
body: S,
signer: &G,
) -> Result<OpId, Error> {
assert!(self.thread.comment(&parent).is_some());
self.transaction("Reply", signer, |tx| tx.reply(parent, body))
}
/// React to an issue comment.
pub fn react<G: Signer>(
&mut self,
@ -391,8 +389,8 @@ impl<'a> Issues<'a> {
) -> Result<IssueMut<'a, 'g>, Error> {
let (id, issue, clock) =
Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
tx.thread(description);
tx.edit(title);
tx.comment(description);
tx.tag(tags.to_owned(), []);
})?;
// Just a sanity check that our clock is advancing as expected.
@ -524,22 +522,37 @@ mod test {
fn test_issue_reply() {
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();
let comment = issue.comment("Ho ho ho.", &signer).unwrap();
let root = (clock::Lamport::default(), author);
issue.reply(comment, "Hi hi hi.", &signer).unwrap();
issue.reply(comment, "Ha ha ha.", &signer).unwrap();
let c1 = issue.comment("Hi hi hi.", root, &signer).unwrap();
let c2 = issue.comment("Ha ha ha.", root, &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();
let mut issue = issues.get_mut(&id).unwrap();
let (_, reply1) = &issue.replies(&root).nth(0).unwrap();
let (_, reply2) = &issue.replies(&root).nth(1).unwrap();
assert_eq!(reply1.body, "Hi hi hi.");
assert_eq!(reply2.body, "Ha ha ha.");
issue.comment("Re: Hi.", c1, &signer).unwrap();
issue.comment("Re: Ha.", c2, &signer).unwrap();
issue.comment("Re: Ha. Ha.", c2, &signer).unwrap();
issue.comment("Re: Ha. Ha. Ha.", c2, &signer).unwrap();
let issue = issues.get(&id).unwrap().unwrap();
assert_eq!(&issue.replies(&c1).nth(0).unwrap().1.body, "Re: Hi.");
assert_eq!(&issue.replies(&c2).nth(0).unwrap().1.body, "Re: Ha.");
assert_eq!(&issue.replies(&c2).nth(1).unwrap().1.body, "Re: Ha. Ha.");
assert_eq!(
&issue.replies(&c2).nth(2).unwrap().1.body,
"Re: Ha. Ha. Ha."
);
}
#[test]
@ -575,8 +588,11 @@ mod test {
.create("My first issue", "Blah blah blah.", &[], &signer)
.unwrap();
issue.comment("Ho ho ho.", &signer).unwrap();
issue.comment("Ha ha ha.", &signer).unwrap();
// The initial thread op id is always the same.
let c0 = (clock::Lamport::initial(), author);
issue.comment("Ho ho ho.", c0, &signer).unwrap();
issue.comment("Ha ha ha.", c0, &signer).unwrap();
let id = issue.id;
let issue = issues.get(&id).unwrap().unwrap();

View File

@ -372,7 +372,8 @@ impl Revision {
}
pub fn description(&self) -> Option<&str> {
self.discussion.first()
let (_, comment) = self.discussion.root()?;
Some(comment.body.as_str())
}
}
@ -534,8 +535,8 @@ impl store::Transaction<Patch> {
})
}
/// Comment on a patch revision.
pub fn comment<S: ToString>(&mut self, revision: RevisionId, body: S) -> OpId {
/// Start a patch revision discussion.
pub fn thread<S: ToString>(&mut self, revision: RevisionId, body: S) -> OpId {
self.push(Action::Thread {
revision,
action: thread::Action::Comment {
@ -545,6 +546,22 @@ impl store::Transaction<Patch> {
})
}
/// Comment on a patch revision.
pub fn comment<S: ToString>(
&mut self,
revision: RevisionId,
body: S,
reply_to: CommentId,
) -> OpId {
self.push(Action::Thread {
revision,
action: thread::Action::Comment {
body: body.to_string(),
reply_to: Some(reply_to),
},
})
}
/// Review a patch revision.
pub fn review(
&mut self,
@ -582,7 +599,7 @@ impl store::Transaction<Patch> {
oid: impl Into<git::Oid>,
) -> (OpId, OpId) {
let revision = self.revision(base, oid);
let comment = self.comment(revision, description);
let comment = self.thread(revision, description);
(revision, comment)
}
@ -664,9 +681,10 @@ impl<'a, 'g> PatchMut<'a, 'g> {
&mut self,
revision: RevisionId,
body: S,
reply_to: CommentId,
signer: &G,
) -> Result<CommentId, Error> {
self.transaction("Comment", signer, |tx| tx.comment(revision, body))
self.transaction("Comment", signer, |tx| tx.comment(revision, body, reply_to))
}
/// Review a patch revision.
@ -703,7 +721,7 @@ impl<'a, 'g> PatchMut<'a, 'g> {
) -> Result<(OpId, OpId), Error> {
self.transaction("Add revision", signer, |tx| {
let r = tx.revision(base, oid);
let c = tx.comment(r, description);
let c = tx.thread(r, description);
(r, c)
})
@ -950,21 +968,18 @@ mod test {
let [p1, p2, p3] = log.permutations;
let mut t1 = t.clone();
match t1.apply(p1) {
Ok(()) => {}
Err(ApplyError::Missing(_)) => return TestResult::discard(),
if t1.apply(p1).is_err() {
return TestResult::discard();
}
let mut t2 = t.clone();
match t2.apply(p2) {
Ok(()) => {}
Err(ApplyError::Missing(_)) => return TestResult::discard(),
if t2.apply(p2).is_err() {
return TestResult::discard();
}
let mut t3 = t;
match t3.apply(p3) {
Ok(()) => {}
Err(ApplyError::Missing(_)) => return TestResult::discard(),
if t3.apply(p3).is_err() {
return TestResult::discard();
}
assert_eq!(t1, t2);
@ -976,7 +991,7 @@ mod test {
qcheck::QuickCheck::new()
.min_tests_passed(100)
.gen(qcheck::Gen::new(8))
.gen(qcheck::Gen::new(7))
.quickcheck(property as fn(Changes<3>) -> TestResult);
}

View File

@ -29,19 +29,20 @@ pub static TYPENAME: Lazy<TypeName> =
pub type CommentId = OpId;
/// A comment on a discussion thread.
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Comment {
/// The comment body.
pub body: String,
/// Thread or comment this is a reply to.
pub reply_to: Option<OpId>,
/// Comment this is a reply to.
/// Should always be set, except for the root comment.
pub reply_to: Option<CommentId>,
/// When the comment was authored.
pub timestamp: Timestamp,
}
impl Comment {
/// Create a new comment.
pub fn new(body: String, reply_to: Option<OpId>, timestamp: Timestamp) -> Self {
pub fn new(body: String, reply_to: Option<CommentId>, timestamp: Timestamp) -> Self {
Self {
body,
reply_to,
@ -68,7 +69,9 @@ pub enum Action {
Comment {
/// Comment body.
body: String,
/// Another comment this is a reply to.
/// Comment this is a reply to.
/// Should be [`None`] if it's the top-level comment.
/// Should be the root [`CommentId`] if it's a top-level comment.
reply_to: Option<OpId>,
},
/// Redact a change. Not all changes can be redacted.
@ -105,8 +108,8 @@ impl store::FromHistory for Thread {
fn from_history(history: &History) -> Result<(Self, Lamport), store::Error> {
let obj = history.traverse(Thread::default(), |mut acc, entry| {
if let Ok(Ops(changes)) = Ops::try_from(entry) {
acc.apply(changes);
if let Ok(Ops(ops)) = Ops::try_from(entry) {
acc.apply(ops);
ControlFlow::Continue(acc)
} else {
ControlFlow::Break(acc)
@ -132,6 +135,10 @@ impl Deref for Thread {
}
impl Thread {
pub fn is_initialized(&self) -> bool {
!self.comments.is_empty()
}
pub fn comment(&self, id: &CommentId) -> Option<&Comment> {
if let Some(Redactable::Present(comment)) = self.comments.get(id) {
Some(comment)
@ -140,11 +147,10 @@ impl Thread {
}
}
pub fn first(&self) -> Option<&str> {
pub fn root(&self) -> Option<(&CommentId, &Comment)> {
self.comments
.values()
.filter_map(|r| r.get())
.map(|c| c.body.as_str())
.iter()
.filter_map(|(id, r)| r.get().map(|comment| (id, comment)))
.next()
}
@ -153,8 +159,8 @@ impl Thread {
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 {
if let Some(reply_to) = c.reply_to {
if &reply_to == to {
return Some((id, c));
}
}
@ -179,8 +185,10 @@ impl Thread {
match op.action {
Action::Comment { body, reply_to } => {
let present = Redactable::Present(Comment::new(body, reply_to, op.timestamp));
self.comments.insert(id, present);
self.comments.insert(
id,
Redactable::Present(Comment::new(body, reply_to, op.timestamp)),
);
}
Action::Redact { id } => {
self.comments.insert(id, Redactable::Redacted);
@ -273,9 +281,9 @@ mod tests {
use std::{array, iter};
use crate::crypto::test::signer::MockSigner;
use nonempty::NonEmpty;
use pretty_assertions::assert_eq;
use qcheck::Arbitrary;
use qcheck_macros::quickcheck;
use qcheck::{Arbitrary, TestResult};
use super::*;
use crate as radicle;
@ -303,6 +311,7 @@ mod tests {
fn arbitrary(g: &mut qcheck::Gen) -> Self {
let author = ActorId::from([0; 32]);
let rng = fastrand::Rng::with_seed(u64::arbitrary(g));
let root = (Lamport::initial(), author);
let gen =
WeightedGenerator::<(Lamport, Action), (Lamport, Vec<OpId>)>::new(rng.clone())
.variant(3, |(clock, changes), rng| {
@ -312,7 +321,7 @@ mod tests {
*clock,
Action::Comment {
body: iter::repeat_with(|| rng.alphabetic()).take(16).collect(),
reply_to: None,
reply_to: Some(root),
},
))
})
@ -340,11 +349,19 @@ mod tests {
Some((clock.tick(), Action::Redact { id }))
});
let mut changes = Vec::new();
let mut changes = vec![Op {
action: Action::Comment {
body: String::default(),
reply_to: None,
},
author,
clock: Lamport::initial(),
timestamp: Timestamp::now(),
}];
let mut permutations: [Vec<Op<Action>>; N] = array::from_fn(|_| Vec::new());
let timestamp = Timestamp::now() + rng.u64(..60);
for (clock, action) in gen.take(g.size().min(8)) {
for (clock, action) in gen.take(g.size()) {
let timestamp = Timestamp::now() + rng.u64(..60);
changes.push(Op {
action,
author,
@ -370,23 +387,24 @@ mod tests {
radicle::cob::store::Store::<Thread>::open(*signer.public_key(), &repository).unwrap();
let mut alice = Actor::new(signer);
let a1 = alice.comment("First comment", None);
let a2 = alice.comment("Second comment", None);
let a3 = alice.comment("Third comment", None);
let a0 = alice.comment("First comment", None);
let a1 = alice.comment("Second comment", Some(a0.id()));
let a2 = alice.comment("Third comment", Some(a0.id()));
let (id, _, _) = store
.create("Thread created", a1.action, &alice.signer)
.create("Thread created", a0.action, &alice.signer)
.unwrap();
let second = store
let comment = store
.update(id, "Thread updated", a1.action, &alice.signer)
.unwrap();
store
.update(id, "Thread updated", a2.action, &alice.signer)
.unwrap();
store
.update(id, "Thread updated", a3.action, &alice.signer)
.unwrap();
let a4 = alice.redact((second.history().clock().into(), *alice.signer.public_key()));
// Redact the second comment.
let a3 = alice.redact((comment.history().clock().into(), *alice.signer.public_key()));
store
.update(id, "Comment redacted", a4.action, &alice.signer)
.update(id, "Comment redacted", a3.action, &alice.signer)
.unwrap();
let (thread, _) = store.get(&id).unwrap().unwrap();
@ -407,17 +425,20 @@ mod tests {
let mut alice = Actor::new(signer);
let a1 = alice.comment("First comment", None);
let a2 = alice.comment("Second comment", None);
let a0 = alice.comment("Thread root", None);
let a1 = alice.comment("First comment", Some(a0.id()));
let a2 = alice.comment("Second comment", Some(a0.id()));
let mut expected = Thread::default();
expected.apply([a1.clone(), a2.clone()]);
expected.apply([a0.clone(), a1.clone(), a2.clone()]);
let (id, _, _) = store
.create("Thread created", a1.action, &alice.signer)
.create("Thread created", a0.action, &alice.signer)
.unwrap();
let actions = NonEmpty::from_vec(vec![a1.action, a2.action]).unwrap();
store
.update(id, "Thread updated", a2.action, &alice.signer)
.update(id, "Thread updated", actions, &alice.signer)
.unwrap();
let (actual, _) = store.get(&id).unwrap().unwrap();
@ -430,18 +451,19 @@ mod tests {
let mut alice = Actor::<MockSigner>::default();
let mut bob = Actor::<MockSigner>::default();
let a1 = alice.comment("First comment", None);
let a2 = alice.comment("Second comment", None);
let a0 = alice.comment("Thread root", None);
let a1 = alice.comment("First comment", Some(a0.id()));
let a2 = alice.comment("Second comment", Some(a0.id()));
bob.receive([a1.clone(), a2.clone()]);
bob.receive([a0.clone(), a1.clone(), a2.clone()]);
assert_eq!(
bob.timeline().collect::<Vec<_>>(),
alice.timeline().collect::<Vec<_>>()
);
assert_eq!(alice.timeline().collect::<Vec<_>>(), vec![&a1, &a2]);
assert_eq!(alice.timeline().collect::<Vec<_>>(), vec![&a0, &a1, &a2]);
bob.reset();
bob.receive([a2, a1]);
bob.receive([a0, a2, a1]);
assert_eq!(
bob.timeline().collect::<Vec<_>>(),
alice.timeline().collect::<Vec<_>>()
@ -454,32 +476,33 @@ mod tests {
let mut bob = Actor::<MockSigner>::default();
let mut eve = Actor::<MockSigner>::default();
let a1 = alice.comment("First comment", None);
let a0 = alice.comment("Thread root", None);
let a1 = alice.comment("First comment", Some(a0.id()));
bob.receive([a1.clone()]);
bob.receive([a0.clone(), a1.clone()]);
let b0 = bob.comment("Bob's first reply to Alice", None);
let b1 = bob.comment("Bob's second reply to Alice", None);
let b0 = bob.comment("Bob's first reply to Alice", Some(a0.id()));
let b1 = bob.comment("Bob's second reply to Alice", Some(a0.id()));
eve.receive([b1.clone(), b0.clone()]);
let e0 = eve.comment("Eve's first reply to Alice", None);
eve.receive([a0.clone(), b1.clone(), b0.clone()]);
let e0 = eve.comment("Eve's first reply to Alice", Some(a0.id()));
bob.receive([e0.clone()]);
let b2 = bob.comment("Bob's third reply to Alice", None);
let b2 = bob.comment("Bob's third reply to Alice", Some(a0.id()));
eve.receive([b2.clone(), a1.clone()]);
let e1 = eve.comment("Eve's second reply to Alice", None);
let e1 = eve.comment("Eve's second reply to Alice", Some(a0.id()));
alice.receive([b0.clone(), b1.clone(), b2.clone(), e0.clone(), e1.clone()]);
bob.receive([e1.clone()]);
let a2 = alice.comment("Second comment", None);
let a2 = alice.comment("Second comment", Some(a0.id()));
eve.receive([a2.clone()]);
bob.receive([a2.clone()]);
assert_eq!(alice.ops.len(), 7);
assert_eq!(bob.ops.len(), 7);
assert_eq!(eve.ops.len(), 7);
assert_eq!(alice.ops.len(), 8);
assert_eq!(bob.ops.len(), 8);
assert_eq!(eve.ops.len(), 8);
assert_eq!(
bob.timeline().collect::<Vec<_>>(),
@ -490,27 +513,36 @@ mod tests {
alice.timeline().collect::<Vec<_>>()
);
assert_eq!(
vec![&a1, &b0, &b1, &e0, &b2, &e1, &a2],
vec![&a0, &a1, &b0, &b1, &e0, &b2, &e1, &a2],
alice.timeline().collect::<Vec<_>>(),
);
}
#[quickcheck]
fn prop_invariants(log: Changes<3>) {
let t = Thread::default();
let [p1, p2, p3] = log.permutations;
#[test]
fn prop_invariants() {
fn property(log: Changes<3>) -> TestResult {
let t = Thread::default();
let [p1, p2, p3] = log.permutations;
let mut t1 = t.clone();
t1.apply(p1);
let mut t1 = t.clone();
t1.apply(p1);
let mut t2 = t.clone();
t2.apply(p2);
let mut t2 = t.clone();
t2.apply(p2);
let mut t3 = t;
t3.apply(p3);
let mut t3 = t;
t3.apply(p3);
assert_eq!(t1, t2);
assert_eq!(t2, t3);
assert_laws(&t1, &t2, &t3);
assert_eq!(t1, t2);
assert_eq!(t2, t3);
assert_laws(&t1, &t2, &t3);
TestResult::passed()
}
qcheck::QuickCheck::new()
.min_tests_passed(100)
.max_tests(10000)
.gen(qcheck::Gen::new(7))
.quickcheck(property as fn(Changes<3>) -> TestResult);
}
}