cob: Work on missing functionality in patches
* Add resolve/unresolve functionalities to threads' comments * Enable reactions on a revision and code locations
This commit is contained in:
parent
54cc92cc83
commit
5466232dd4
|
|
@ -18,7 +18,7 @@ use crate::cob::store::Transaction;
|
||||||
use crate::cob::store::{FromHistory as _, HistoryAction};
|
use crate::cob::store::{FromHistory as _, HistoryAction};
|
||||||
use crate::cob::thread;
|
use crate::cob::thread;
|
||||||
use crate::cob::thread::Thread;
|
use crate::cob::thread::Thread;
|
||||||
use crate::cob::thread::{Comment, CommentId};
|
use crate::cob::thread::{Comment, CommentId, Reactions};
|
||||||
use crate::cob::{store, ActorId, EntryId, ObjectId, TypeName};
|
use crate::cob::{store, ActorId, EntryId, ObjectId, TypeName};
|
||||||
use crate::crypto::{PublicKey, Signer};
|
use crate::crypto::{PublicKey, Signer};
|
||||||
use crate::git;
|
use crate::git;
|
||||||
|
|
@ -162,7 +162,7 @@ pub enum Action {
|
||||||
summary: Option<String>,
|
summary: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "review.redact")]
|
#[serde(rename = "review.redact")]
|
||||||
ReviewRedact { review: EntryId },
|
ReviewRedact { review: ReviewId },
|
||||||
#[serde(rename = "review.comment")]
|
#[serde(rename = "review.comment")]
|
||||||
ReviewComment {
|
ReviewComment {
|
||||||
review: ReviewId,
|
review: ReviewId,
|
||||||
|
|
@ -571,8 +571,8 @@ impl store::FromHistory for Patch {
|
||||||
Action::Label { labels } => {
|
Action::Label { labels } => {
|
||||||
self.labels = BTreeSet::from_iter(labels);
|
self.labels = BTreeSet::from_iter(labels);
|
||||||
}
|
}
|
||||||
Action::Assign { .. } => {
|
Action::Assign { assignees } => {
|
||||||
todo!();
|
self.assignees = BTreeSet::from_iter(assignees.into_iter().map(ActorId::from));
|
||||||
}
|
}
|
||||||
Action::RevisionEdit {
|
Action::RevisionEdit {
|
||||||
revision,
|
revision,
|
||||||
|
|
@ -631,8 +631,22 @@ impl store::FromHistory for Patch {
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Action::RevisionReact { .. } => {
|
Action::RevisionReact {
|
||||||
todo!();
|
revision,
|
||||||
|
reaction,
|
||||||
|
active,
|
||||||
|
location,
|
||||||
|
} => {
|
||||||
|
if let Some(revision) = lookup::revision(self, &revision)? {
|
||||||
|
let key = (op.author, reaction);
|
||||||
|
let reactions = revision.reactions.entry(location).or_default();
|
||||||
|
|
||||||
|
if active {
|
||||||
|
reactions.insert(key);
|
||||||
|
} else {
|
||||||
|
reactions.remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Action::RevisionRedact { revision } => {
|
Action::RevisionRedact { revision } => {
|
||||||
// Redactions must have observed a revision to be valid.
|
// Redactions must have observed a revision to be valid.
|
||||||
|
|
@ -706,11 +720,15 @@ impl store::FromHistory for Patch {
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::ReviewCommentResolve { .. } => {
|
Action::ReviewCommentResolve { review, comment } => {
|
||||||
todo!();
|
if let Some(review) = lookup::review(self, &review)? {
|
||||||
|
thread::resolve(&mut review.comments, op.id, comment)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Action::ReviewCommentUnresolve { .. } => {
|
Action::ReviewCommentUnresolve { review, comment } => {
|
||||||
todo!();
|
if let Some(review) = lookup::review(self, &review)? {
|
||||||
|
thread::unresolve(&mut review.comments, op.id, comment)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Action::ReviewComment {
|
Action::ReviewComment {
|
||||||
review,
|
review,
|
||||||
|
|
@ -731,8 +749,30 @@ impl store::FromHistory for Patch {
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::ReviewRedact { .. } => {
|
Action::ReviewRedact { review } => {
|
||||||
todo!();
|
// Redactions must have observed a review to be valid.
|
||||||
|
let Some(locator) = self.reviews.get_mut(&review) else {
|
||||||
|
return Err(Error::Missing(review.into_inner()));
|
||||||
|
};
|
||||||
|
// If the review is already redacted, do nothing.
|
||||||
|
let Some((revision, reviewer)) = locator else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// The revision must have existed at some point.
|
||||||
|
let Some(redactable) = self.revisions.get_mut(revision) else {
|
||||||
|
return Err(Error::Missing(revision.into_inner()));
|
||||||
|
};
|
||||||
|
// But it could be redacted.
|
||||||
|
let Some(revision) = redactable else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// The review must have existed as well.
|
||||||
|
let Some(review) = revision.reviews.get_mut(reviewer) else {
|
||||||
|
return Err(Error::Missing(review.into_inner()));
|
||||||
|
};
|
||||||
|
// Set both the review and the locator in the review index to redacted.
|
||||||
|
*review = None;
|
||||||
|
*locator = None;
|
||||||
}
|
}
|
||||||
Action::Merge { revision, commit } => {
|
Action::Merge { revision, commit } => {
|
||||||
// If the revision was redacted before the merge, ignore the merge.
|
// If the revision was redacted before the merge, ignore the merge.
|
||||||
|
|
@ -930,6 +970,8 @@ pub struct Revision {
|
||||||
pub(super) timestamp: Timestamp,
|
pub(super) timestamp: Timestamp,
|
||||||
/// Review comments resolved by this revision.
|
/// Review comments resolved by this revision.
|
||||||
pub(super) resolves: BTreeSet<(EntryId, CommentId)>,
|
pub(super) resolves: BTreeSet<(EntryId, CommentId)>,
|
||||||
|
/// Reactions on code locations and revision itself
|
||||||
|
pub(super) reactions: BTreeMap<Option<CodeLocation>, Reactions>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Revision {
|
impl Revision {
|
||||||
|
|
@ -950,6 +992,7 @@ impl Revision {
|
||||||
reviews: BTreeMap::default(),
|
reviews: BTreeMap::default(),
|
||||||
timestamp,
|
timestamp,
|
||||||
resolves,
|
resolves,
|
||||||
|
reactions: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1083,8 +1126,37 @@ pub enum CodeRange {
|
||||||
Chars { line: usize, range: Range<usize> },
|
Chars { line: usize, range: Range<usize> },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::cmp::PartialOrd for CodeRange {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::cmp::Ord for CodeRange {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
match (self, other) {
|
||||||
|
(CodeRange::Lines { .. }, CodeRange::Chars { .. }) => std::cmp::Ordering::Less,
|
||||||
|
(CodeRange::Chars { .. }, CodeRange::Lines { .. }) => std::cmp::Ordering::Greater,
|
||||||
|
|
||||||
|
(CodeRange::Lines { range: a }, CodeRange::Lines { range: b }) => {
|
||||||
|
a.clone().cmp(b.clone())
|
||||||
|
}
|
||||||
|
(
|
||||||
|
CodeRange::Chars {
|
||||||
|
line: l1,
|
||||||
|
range: r1,
|
||||||
|
},
|
||||||
|
CodeRange::Chars {
|
||||||
|
line: l2,
|
||||||
|
range: r2,
|
||||||
|
},
|
||||||
|
) => l1.cmp(l2).then(r1.clone().cmp(r2.clone())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Code location, used for attaching comments to diffs.
|
/// Code location, used for attaching comments to diffs.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CodeLocation {
|
pub struct CodeLocation {
|
||||||
/// Path of file.
|
/// Path of file.
|
||||||
|
|
@ -1345,6 +1417,11 @@ impl store::Transaction<Patch> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Redact a patch review.
|
||||||
|
pub fn redact_review(&mut self, review: ReviewId) -> Result<(), store::Error> {
|
||||||
|
self.push(Action::ReviewRedact { review })
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge a patch revision.
|
/// Merge a patch revision.
|
||||||
pub fn merge(&mut self, revision: RevisionId, commit: git::Oid) -> Result<(), store::Error> {
|
pub fn merge(&mut self, revision: RevisionId, commit: git::Oid) -> Result<(), store::Error> {
|
||||||
self.push(Action::Merge { revision, commit })
|
self.push(Action::Merge { revision, commit })
|
||||||
|
|
@ -1397,6 +1474,16 @@ where
|
||||||
&self.id
|
&self.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reload the patch data from storage.
|
||||||
|
pub fn reload(&mut self) -> Result<(), store::Error> {
|
||||||
|
self.patch = self
|
||||||
|
.store
|
||||||
|
.get(&self.id)?
|
||||||
|
.ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn transaction<G, F>(
|
pub fn transaction<G, F>(
|
||||||
&mut self,
|
&mut self,
|
||||||
message: &str,
|
message: &str,
|
||||||
|
|
@ -1559,7 +1646,7 @@ where
|
||||||
active: bool,
|
active: bool,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<EntryId, Error> {
|
) -> Result<EntryId, Error> {
|
||||||
self.transaction("React review comment", signer, |tx| {
|
self.transaction("React to review comment", signer, |tx| {
|
||||||
tx.react_review_comment(review, comment, reaction, active)
|
tx.react_review_comment(review, comment, reaction, active)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1571,7 +1658,7 @@ where
|
||||||
comment: EntryId,
|
comment: EntryId,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<EntryId, Error> {
|
) -> Result<EntryId, Error> {
|
||||||
self.transaction("React review comment", signer, |tx| {
|
self.transaction("Redact review comment", signer, |tx| {
|
||||||
tx.redact_review_comment(review, comment)
|
tx.redact_review_comment(review, comment)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1591,6 +1678,15 @@ where
|
||||||
.map(ReviewId)
|
.map(ReviewId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Redact a patch review.
|
||||||
|
pub fn redact_review<G: Signer>(
|
||||||
|
&mut self,
|
||||||
|
review: ReviewId,
|
||||||
|
signer: &G,
|
||||||
|
) -> Result<EntryId, Error> {
|
||||||
|
self.transaction("Redact review", signer, |tx| tx.redact_review(review))
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge a patch revision.
|
/// Merge a patch revision.
|
||||||
pub fn merge<G: Signer>(
|
pub fn merge<G: Signer>(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -2014,10 +2110,10 @@ mod test {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (rid, _) = patch.latest();
|
let (revision_id, _) = patch.latest();
|
||||||
patch
|
let review_id = patch
|
||||||
.review(
|
.review(
|
||||||
rid,
|
revision_id,
|
||||||
Some(Verdict::Accept),
|
Some(Verdict::Accept),
|
||||||
Some("LGTM".to_owned()),
|
Some("LGTM".to_owned()),
|
||||||
vec![],
|
vec![],
|
||||||
|
|
@ -2026,13 +2122,57 @@ mod test {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let id = patch.id;
|
let id = patch.id;
|
||||||
let patch = patches.get(&id).unwrap().unwrap();
|
let mut patch = patches.get_mut(&id).unwrap();
|
||||||
let (_, revision) = patch.latest();
|
let (_, revision) = patch.latest();
|
||||||
assert_eq!(revision.reviews.len(), 1);
|
assert_eq!(revision.reviews.len(), 1);
|
||||||
|
|
||||||
let review = revision.review(alice.signer.public_key()).unwrap();
|
let review = revision.review(alice.signer.public_key()).unwrap();
|
||||||
assert_eq!(review.verdict(), Some(Verdict::Accept));
|
assert_eq!(review.verdict(), Some(Verdict::Accept));
|
||||||
assert_eq!(review.summary(), Some("LGTM"));
|
assert_eq!(review.summary(), Some("LGTM"));
|
||||||
|
|
||||||
|
patch.redact_review(review_id, &alice.signer).unwrap();
|
||||||
|
patch.reload().unwrap();
|
||||||
|
|
||||||
|
let (_, revision) = patch.latest();
|
||||||
|
assert_eq!(revision.reviews().count(), 0);
|
||||||
|
|
||||||
|
// This is fine, redacting an already-redacted review is a no-op.
|
||||||
|
patch.redact_review(review_id, &alice.signer).unwrap();
|
||||||
|
// If the review never existed, it's an error.
|
||||||
|
patch
|
||||||
|
.redact_review(ReviewId(arbitrary::entry_id()), &alice.signer)
|
||||||
|
.unwrap_err();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_patch_review_revision_redact() {
|
||||||
|
let alice = test::setup::NodeWithRepo::default();
|
||||||
|
let checkout = alice.repo.checkout();
|
||||||
|
let branch = checkout.branch_with([("README", b"Hello World!")]);
|
||||||
|
let mut patches = Patches::open(&*alice.repo).unwrap();
|
||||||
|
let mut patch = patches
|
||||||
|
.create(
|
||||||
|
"My first patch",
|
||||||
|
"Blah blah blah.",
|
||||||
|
MergeTarget::Delegates,
|
||||||
|
branch.base,
|
||||||
|
branch.oid,
|
||||||
|
&[],
|
||||||
|
&alice.signer,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let update = checkout.branch_with([("README", b"Hello Radicle!")]);
|
||||||
|
let updated = patch
|
||||||
|
.update("I've made changes.", branch.base, update.oid, &alice.signer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// It's fine to redact a review from a redacted revision.
|
||||||
|
let review = patch
|
||||||
|
.review(updated, None, None, vec![], &alice.signer)
|
||||||
|
.unwrap();
|
||||||
|
patch.redact(updated, &alice.signer).unwrap();
|
||||||
|
patch.redact_review(review, &alice.signer).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -2123,6 +2263,38 @@ mod test {
|
||||||
assert_eq!(patch.revisions().count(), 0);
|
assert_eq!(patch.revisions().count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_revision_reaction() {
|
||||||
|
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
|
||||||
|
let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
|
||||||
|
let mut alice = Actor::new(MockSigner::default());
|
||||||
|
let repo = gen::<MockRepository>(1);
|
||||||
|
let reaction = Reaction::new('👍').expect("failed to create a reaction");
|
||||||
|
|
||||||
|
let a1 = alice.op::<Patch>(Action::Revision {
|
||||||
|
description: String::new(),
|
||||||
|
base,
|
||||||
|
oid,
|
||||||
|
resolves: Default::default(),
|
||||||
|
});
|
||||||
|
let a2 = alice.op::<Patch>(Action::RevisionReact {
|
||||||
|
revision: RevisionId(a1.id()),
|
||||||
|
location: None,
|
||||||
|
reaction,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
let patch = Patch::from_ops([a1, a2], &repo).unwrap();
|
||||||
|
|
||||||
|
let (_, r1) = patch.revisions().next().unwrap();
|
||||||
|
assert!(!r1.reactions.is_empty());
|
||||||
|
|
||||||
|
let mut reactions = r1.reactions.get(&None).unwrap().clone();
|
||||||
|
assert!(!reactions.is_empty());
|
||||||
|
|
||||||
|
let (_, first_reaction) = reactions.pop_first().unwrap();
|
||||||
|
assert_eq!(first_reaction, reaction);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_patch_review_edit() {
|
fn test_patch_review_edit() {
|
||||||
let alice = test::setup::NodeWithRepo::default();
|
let alice = test::setup::NodeWithRepo::default();
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ pub enum Error {
|
||||||
/// Identifies a comment.
|
/// Identifies a comment.
|
||||||
pub type CommentId = EntryId;
|
pub type CommentId = EntryId;
|
||||||
|
|
||||||
|
/// Reactions to a comment or other action.
|
||||||
|
pub type Reactions = BTreeSet<(ActorId, Reaction)>;
|
||||||
|
|
||||||
/// A comment edit is just some text and an edit time.
|
/// A comment edit is just some text and an edit time.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct Edit {
|
pub struct Edit {
|
||||||
|
|
@ -61,12 +64,14 @@ pub struct Comment<T = ()> {
|
||||||
/// The comment body.
|
/// The comment body.
|
||||||
edits: Vec<Edit>,
|
edits: Vec<Edit>,
|
||||||
/// Reactions to this comment.
|
/// Reactions to this comment.
|
||||||
reactions: BTreeSet<(ActorId, Reaction)>,
|
reactions: Reactions,
|
||||||
/// Comment this is a reply to.
|
/// Comment this is a reply to.
|
||||||
/// Should always be set, except for the root comment.
|
/// Should always be set, except for the root comment.
|
||||||
reply_to: Option<CommentId>,
|
reply_to: Option<CommentId>,
|
||||||
/// Location of comment, if this is an inline comment.
|
/// Location of comment, if this is an inline comment.
|
||||||
location: Option<T>,
|
location: Option<T>,
|
||||||
|
/// Whether the comment has been resolved.
|
||||||
|
resolved: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Serialize> Serialize for Comment<T> {
|
impl<T: Serialize> Serialize for Comment<T> {
|
||||||
|
|
@ -115,6 +120,7 @@ impl<L> Comment<L> {
|
||||||
edits: vec![edit],
|
edits: vec![edit],
|
||||||
reply_to,
|
reply_to,
|
||||||
location,
|
location,
|
||||||
|
resolved: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,6 +182,14 @@ impl<L> Comment<L> {
|
||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
&self.edits.last().unwrap().embeds
|
&self.edits.last().unwrap().embeds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve(&mut self) {
|
||||||
|
self.resolved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unresolve(&mut self) {
|
||||||
|
self.resolved = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: PartialOrd> PartialOrd for Comment<T> {
|
impl<T: PartialOrd> PartialOrd for Comment<T> {
|
||||||
|
|
@ -440,6 +454,40 @@ pub fn react<T>(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve<T>(
|
||||||
|
thread: &mut Thread<Comment<T>>,
|
||||||
|
id: EntryId,
|
||||||
|
comment: EntryId,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let Some(comment) = thread.comments.get_mut(&comment) else {
|
||||||
|
return Err(Error::Missing(comment));
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(comment) = comment {
|
||||||
|
debug_assert!(!thread.timeline.contains(&id));
|
||||||
|
thread.timeline.push(id);
|
||||||
|
comment.resolve();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unresolve<T>(
|
||||||
|
thread: &mut Thread<Comment<T>>,
|
||||||
|
id: EntryId,
|
||||||
|
comment: EntryId,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let Some(comment) = thread.comments.get_mut(&comment) else {
|
||||||
|
return Err(Error::Missing(comment));
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(comment) = comment {
|
||||||
|
debug_assert!(!thread.timeline.contains(&id));
|
||||||
|
thread.timeline.push(id);
|
||||||
|
comment.unresolve();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue