radicle: improve reviews
The `Review::summary` was limited in behaviour, since it was only an `Option<String>`. The field is now changed to `NonEmpty<Edit>` so that it can enable an edit history, as well as being able to supply embeds as part of the text. `Reactions` are also introduced to `Review` and, necessarily, introducing an `Action::ReviewReact`. Note that some machinery was added under `actions` and `encoding` to enable backwards-compatibility. For `actions`, a new `ReviewEdit` is introduced to keep track of the different versions of editing actions – supporting the old actions while also introducing the new variant. For `encoding`, the machinery exploits the fact that an `Option<String>` can be converted into a `NonEmpty<Edit>` given that an `ActorId` and `Timestamp` are available, and that `None` can be converted into an empty string. The struct `patch::Review` now deserializes via this `encoding::review::Review` type and, importantly, preserves backwards-compatibility.
This commit is contained in:
parent
c30298fb8a
commit
34939253f7
|
|
@ -802,6 +802,16 @@ version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
|
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "erased-serde"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"typeid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.13"
|
version = "0.3.13"
|
||||||
|
|
@ -2471,6 +2481,7 @@ dependencies = [
|
||||||
"radicle-ssh",
|
"radicle-ssh",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde-untagged",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"siphasher 1.0.1",
|
"siphasher 1.0.1",
|
||||||
"sqlite",
|
"sqlite",
|
||||||
|
|
@ -3012,6 +3023,17 @@ dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde-untagged"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "299d9c19d7d466db4ab10addd5703e4c615dec2a5a16dbbafe191045e87ee66e"
|
||||||
|
dependencies = [
|
||||||
|
"erased-serde",
|
||||||
|
"serde",
|
||||||
|
"typeid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive"
|
name = "serde_derive"
|
||||||
version = "1.0.219"
|
version = "1.0.219"
|
||||||
|
|
@ -3603,6 +3625,12 @@ dependencies = [
|
||||||
"tree-sitter-language",
|
"tree-sitter-language",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typeid"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.17.0"
|
version = "1.17.0"
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ radicle-ssh = { workspace = true }
|
||||||
schemars = { workspace = true, optional = true }
|
schemars = { workspace = true, optional = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||||
|
serde-untagged = "0.1.7"
|
||||||
siphasher = "1.0.0"
|
siphasher = "1.0.0"
|
||||||
sqlite = { workspace = true, features = ["bundled"] }
|
sqlite = { workspace = true, features = ["bundled"] }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
|
|
||||||
|
mod actions;
|
||||||
|
pub use actions::ReviewEdit;
|
||||||
|
|
||||||
|
mod encoding;
|
||||||
|
|
||||||
use std::collections::btree_map;
|
use std::collections::btree_map;
|
||||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
@ -187,16 +192,6 @@ pub enum Action {
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
labels: Vec<Label>,
|
labels: Vec<Label>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "review.edit")]
|
|
||||||
ReviewEdit {
|
|
||||||
review: ReviewId,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
summary: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
verdict: Option<Verdict>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
labels: Vec<Label>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "review.redact")]
|
#[serde(rename = "review.redact")]
|
||||||
ReviewRedact { review: ReviewId },
|
ReviewRedact { review: ReviewId },
|
||||||
#[serde(rename = "review.comment")]
|
#[serde(rename = "review.comment")]
|
||||||
|
|
@ -234,6 +229,13 @@ pub enum Action {
|
||||||
ReviewCommentResolve { review: ReviewId, comment: EntryId },
|
ReviewCommentResolve { review: ReviewId, comment: EntryId },
|
||||||
#[serde(rename = "review.comment.unresolve")]
|
#[serde(rename = "review.comment.unresolve")]
|
||||||
ReviewCommentUnresolve { review: ReviewId, comment: EntryId },
|
ReviewCommentUnresolve { review: ReviewId, comment: EntryId },
|
||||||
|
/// React to the review.
|
||||||
|
#[serde(rename = "review.react")]
|
||||||
|
ReviewReact {
|
||||||
|
review: ReviewId,
|
||||||
|
reaction: Reaction,
|
||||||
|
active: bool,
|
||||||
|
},
|
||||||
|
|
||||||
//
|
//
|
||||||
// Revision actions
|
// Revision actions
|
||||||
|
|
@ -307,6 +309,12 @@ pub enum Action {
|
||||||
reaction: Reaction,
|
reaction: Reaction,
|
||||||
active: bool,
|
active: bool,
|
||||||
},
|
},
|
||||||
|
/// Edit a review's summary, verdict, labels, and embeds.
|
||||||
|
// Note that the tags live on `actions::ReviewEdit`, and according to the
|
||||||
|
// serde.rs docs, it must come after the other variants due to the
|
||||||
|
// `untagged` declaration.
|
||||||
|
#[serde(untagged)]
|
||||||
|
ReviewEdit(actions::ReviewEdit),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CobAction for Action {
|
impl CobAction for Action {
|
||||||
|
|
@ -674,7 +682,7 @@ impl Patch {
|
||||||
},
|
},
|
||||||
// Anyone can submit a review.
|
// Anyone can submit a review.
|
||||||
Action::Review { .. } => Authorization::Allow,
|
Action::Review { .. } => Authorization::Allow,
|
||||||
Action::ReviewRedact { review, .. } | Action::ReviewEdit { review, .. } => {
|
Action::ReviewRedact { review, .. } => {
|
||||||
if let Some((_, review)) = lookup::review(self, review)? {
|
if let Some((_, review)) = lookup::review(self, review)? {
|
||||||
Authorization::from(actor == review.author.public_key())
|
Authorization::from(actor == review.author.public_key())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -682,6 +690,14 @@ impl Patch {
|
||||||
Authorization::Unknown
|
Authorization::Unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Action::ReviewEdit(edit) => {
|
||||||
|
if let Some((_, review)) = lookup::review(self, edit.review_id())? {
|
||||||
|
Authorization::from(actor == review.author.public_key())
|
||||||
|
} else {
|
||||||
|
// Redacted.
|
||||||
|
Authorization::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
// Anyone can comment on a review.
|
// Anyone can comment on a review.
|
||||||
Action::ReviewComment { .. } => Authorization::Allow,
|
Action::ReviewComment { .. } => Authorization::Allow,
|
||||||
// The comment author can edit and redact their own comment.
|
// The comment author can edit and redact their own comment.
|
||||||
|
|
@ -714,6 +730,7 @@ impl Patch {
|
||||||
// Redacted.
|
// Redacted.
|
||||||
Authorization::Unknown
|
Authorization::Unknown
|
||||||
}
|
}
|
||||||
|
Action::ReviewReact { .. } => Authorization::Allow,
|
||||||
// Anyone can propose revisions.
|
// Anyone can propose revisions.
|
||||||
Action::Revision { .. } => Authorization::Allow,
|
Action::Revision { .. } => Authorization::Allow,
|
||||||
// Only the revision author can edit or redact their revision.
|
// Only the revision author can edit or redact their revision.
|
||||||
|
|
@ -896,7 +913,7 @@ impl Patch {
|
||||||
}
|
}
|
||||||
Action::Review {
|
Action::Review {
|
||||||
revision,
|
revision,
|
||||||
ref summary,
|
summary,
|
||||||
verdict,
|
verdict,
|
||||||
labels,
|
labels,
|
||||||
} => {
|
} => {
|
||||||
|
|
@ -914,8 +931,9 @@ impl Patch {
|
||||||
id,
|
id,
|
||||||
Author::new(author),
|
Author::new(author),
|
||||||
verdict,
|
verdict,
|
||||||
summary.to_owned(),
|
summary.unwrap_or_default(),
|
||||||
labels,
|
labels,
|
||||||
|
vec![],
|
||||||
timestamp,
|
timestamp,
|
||||||
));
|
));
|
||||||
// Update reviews index.
|
// Update reviews index.
|
||||||
|
|
@ -928,22 +946,7 @@ impl Patch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::ReviewEdit {
|
Action::ReviewEdit(edit) => edit.run(author, timestamp, self)?,
|
||||||
review,
|
|
||||||
summary,
|
|
||||||
verdict,
|
|
||||||
labels,
|
|
||||||
} => {
|
|
||||||
if summary.is_none() && verdict.is_none() {
|
|
||||||
return Err(Error::EmptyReview);
|
|
||||||
}
|
|
||||||
let Some(review) = lookup::review_mut(self, &review)? else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
review.verdict = verdict;
|
|
||||||
review.summary = summary;
|
|
||||||
review.labels = labels;
|
|
||||||
}
|
|
||||||
Action::ReviewCommentReact {
|
Action::ReviewCommentReact {
|
||||||
review,
|
review,
|
||||||
comment,
|
comment,
|
||||||
|
|
@ -1042,6 +1045,19 @@ impl Patch {
|
||||||
// Set the review locator in the review index to redacted.
|
// Set the review locator in the review index to redacted.
|
||||||
*locator = None;
|
*locator = None;
|
||||||
}
|
}
|
||||||
|
Action::ReviewReact {
|
||||||
|
review,
|
||||||
|
reaction,
|
||||||
|
active,
|
||||||
|
} => {
|
||||||
|
if let Some(review) = lookup::review_mut(self, &review)? {
|
||||||
|
if active {
|
||||||
|
review.reactions.insert((author, reaction));
|
||||||
|
} else {
|
||||||
|
review.reactions.remove(&(author, reaction));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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.
|
||||||
if lookup::revision_mut(self, &revision)?.is_none() {
|
if lookup::revision_mut(self, &revision)?.is_none() {
|
||||||
|
|
@ -1613,6 +1629,7 @@ impl fmt::Display for Verdict {
|
||||||
/// A patch review on a revision.
|
/// A patch review on a revision.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[serde(from = "encoding::review::Review")]
|
||||||
pub struct Review {
|
pub struct Review {
|
||||||
/// Review identifier.
|
/// Review identifier.
|
||||||
pub(super) id: ReviewId,
|
pub(super) id: ReviewId,
|
||||||
|
|
@ -1624,13 +1641,19 @@ pub struct Review {
|
||||||
pub(super) verdict: Option<Verdict>,
|
pub(super) verdict: Option<Verdict>,
|
||||||
/// Review summary.
|
/// Review summary.
|
||||||
///
|
///
|
||||||
/// Can be edited or set to `None`.
|
/// Can be empty, given there is a [`Verdict`].
|
||||||
pub(super) summary: Option<String>,
|
///
|
||||||
|
/// If not empty, then the last [`Edit`] in the `Vec` will be the latest
|
||||||
|
/// edit of the summary.
|
||||||
|
pub(super) summary: NonEmpty<Edit>,
|
||||||
/// Review comments.
|
/// Review comments.
|
||||||
pub(super) comments: Thread<Comment<CodeLocation>>,
|
pub(super) comments: Thread<Comment<CodeLocation>>,
|
||||||
/// Labels qualifying the review. For example if this review only looks at the
|
/// Labels qualifying the review. For example if this review only looks at the
|
||||||
/// concept or intention of the patch, it could have a "concept" label.
|
/// concept or intention of the patch, it could have a "concept" label.
|
||||||
pub(super) labels: Vec<Label>,
|
pub(super) labels: Vec<Label>,
|
||||||
|
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
|
||||||
|
/// Reactions to the review.
|
||||||
|
pub(super) reactions: Reactions,
|
||||||
/// Review timestamp.
|
/// Review timestamp.
|
||||||
pub(super) timestamp: Timestamp,
|
pub(super) timestamp: Timestamp,
|
||||||
}
|
}
|
||||||
|
|
@ -1640,16 +1663,19 @@ impl Review {
|
||||||
id: ReviewId,
|
id: ReviewId,
|
||||||
author: Author,
|
author: Author,
|
||||||
verdict: Option<Verdict>,
|
verdict: Option<Verdict>,
|
||||||
summary: Option<String>,
|
summary: String,
|
||||||
labels: Vec<Label>,
|
labels: Vec<Label>,
|
||||||
|
embeds: Vec<Embed<Uri>>,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let summary = NonEmpty::new(Edit::new(*author.public_key(), summary, timestamp, embeds));
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
author,
|
author,
|
||||||
verdict,
|
verdict,
|
||||||
summary,
|
summary,
|
||||||
comments: Thread::default(),
|
comments: Thread::default(),
|
||||||
|
reactions: BTreeSet::new(),
|
||||||
labels,
|
labels,
|
||||||
timestamp,
|
timestamp,
|
||||||
}
|
}
|
||||||
|
|
@ -1681,8 +1707,23 @@ impl Review {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Review general comment.
|
/// Review general comment.
|
||||||
pub fn summary(&self) -> Option<&str> {
|
pub fn summary(&self) -> &str {
|
||||||
self.summary.as_deref()
|
self.summary.last().body.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Review embeds.
|
||||||
|
pub fn embeds(&self) -> &[Embed<Uri>] {
|
||||||
|
&self.summary.last().embeds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Review reactions.
|
||||||
|
pub fn reactions(&self) -> &Reactions {
|
||||||
|
&self.reactions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the review summary edits.
|
||||||
|
pub fn edits(&self) -> impl Iterator<Item = &Edit> {
|
||||||
|
self.summary.iter()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Review timestamp.
|
/// Review timestamp.
|
||||||
|
|
@ -1911,15 +1952,17 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
|
||||||
&mut self,
|
&mut self,
|
||||||
review: ReviewId,
|
review: ReviewId,
|
||||||
verdict: Option<Verdict>,
|
verdict: Option<Verdict>,
|
||||||
summary: Option<String>,
|
summary: String,
|
||||||
labels: Vec<Label>,
|
labels: Vec<Label>,
|
||||||
|
embeds: impl IntoIterator<Item = Embed<Uri>>,
|
||||||
) -> Result<(), store::Error> {
|
) -> Result<(), store::Error> {
|
||||||
self.push(Action::ReviewEdit {
|
self.push(Action::ReviewEdit(actions::ReviewEdit::new(
|
||||||
review,
|
review,
|
||||||
summary,
|
summary,
|
||||||
verdict,
|
verdict,
|
||||||
labels,
|
labels,
|
||||||
})
|
embeds.into_iter().collect(),
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Redact a patch review.
|
/// Redact a patch review.
|
||||||
|
|
@ -2272,15 +2315,16 @@ where
|
||||||
&mut self,
|
&mut self,
|
||||||
review: ReviewId,
|
review: ReviewId,
|
||||||
verdict: Option<Verdict>,
|
verdict: Option<Verdict>,
|
||||||
summary: Option<String>,
|
summary: String,
|
||||||
labels: Vec<Label>,
|
labels: Vec<Label>,
|
||||||
|
embeds: impl IntoIterator<Item = Embed<Uri>>,
|
||||||
signer: &Device<G>,
|
signer: &Device<G>,
|
||||||
) -> Result<EntryId, Error>
|
) -> Result<EntryId, Error>
|
||||||
where
|
where
|
||||||
G: crypto::signature::Signer<crypto::Signature>,
|
G: crypto::signature::Signer<crypto::Signature>,
|
||||||
{
|
{
|
||||||
self.transaction("Edit review", signer, |tx| {
|
self.transaction("Edit review", signer, |tx| {
|
||||||
tx.review_edit(review, verdict, summary, labels)
|
tx.review_edit(review, verdict, summary, labels, embeds)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3109,7 +3153,7 @@ mod test {
|
||||||
|
|
||||||
let review = revision.review_by(alice.signer.public_key()).unwrap();
|
let review = revision.review_by(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(), "LGTM");
|
||||||
|
|
||||||
patch.redact_review(review_id, &alice.signer).unwrap();
|
patch.redact_review(review_id, &alice.signer).unwrap();
|
||||||
patch.reload().unwrap();
|
patch.reload().unwrap();
|
||||||
|
|
@ -3345,7 +3389,8 @@ mod test {
|
||||||
.review_edit(
|
.review_edit(
|
||||||
review,
|
review,
|
||||||
Some(Verdict::Reject),
|
Some(Verdict::Reject),
|
||||||
Some("Whoops!".to_owned()),
|
"Whoops!".to_owned(),
|
||||||
|
vec![],
|
||||||
vec![],
|
vec![],
|
||||||
&alice.signer,
|
&alice.signer,
|
||||||
)
|
)
|
||||||
|
|
@ -3354,7 +3399,7 @@ mod test {
|
||||||
let (_, revision) = patch.latest();
|
let (_, revision) = patch.latest();
|
||||||
let review = revision.review_by(alice.signer.public_key()).unwrap();
|
let review = revision.review_by(alice.signer.public_key()).unwrap();
|
||||||
assert_eq!(review.verdict(), Some(Verdict::Reject));
|
assert_eq!(review.verdict(), Some(Verdict::Reject));
|
||||||
assert_eq!(review.summary(), Some("Whoops!"));
|
assert_eq!(review.summary(), "Whoops!");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -3415,7 +3460,14 @@ mod test {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let _review = patch
|
let _review = patch
|
||||||
.review_edit(review, Some(Verdict::Reject), None, vec![], &alice.signer)
|
.review_edit(
|
||||||
|
review,
|
||||||
|
Some(Verdict::Reject),
|
||||||
|
"".to_string(),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
&alice.signer,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
patch
|
patch
|
||||||
.review_comment(review, "Second comment!", None, None, [], &alice.signer)
|
.review_comment(review, "Second comment!", None, None, [], &alice.signer)
|
||||||
|
|
@ -3508,7 +3560,14 @@ mod test {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
patch
|
patch
|
||||||
.review_edit(review, Some(Verdict::Accept), None, vec![], &alice.signer)
|
.review_edit(
|
||||||
|
review,
|
||||||
|
Some(Verdict::Accept),
|
||||||
|
"".to_string(),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
&alice.signer,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let id = patch.id;
|
let id = patch.id;
|
||||||
|
|
@ -3517,7 +3576,7 @@ mod test {
|
||||||
let review = revision.review_by(alice.signer.public_key()).unwrap();
|
let review = revision.review_by(alice.signer.public_key()).unwrap();
|
||||||
|
|
||||||
assert_eq!(review.verdict(), Some(Verdict::Accept));
|
assert_eq!(review.verdict(), Some(Verdict::Accept));
|
||||||
assert_eq!(review.summary(), None);
|
assert_eq!(review.summary(), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
//! Keep track of the patch [`Action`] versions, to ensure compatibility where
|
||||||
|
//! possible.
|
||||||
|
//!
|
||||||
|
//! [`Action`]: super::Action
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::cob::{thread::Edit, ActorId, Embed, Label, Timestamp, Uri};
|
||||||
|
|
||||||
|
use super::{lookup, Error, Patch, ReviewId, Verdict};
|
||||||
|
|
||||||
|
/// A review edit that keeps track of the different versions of actions.
|
||||||
|
///
|
||||||
|
/// [`ReviewEdit::new`] will create the latest version of the action.
|
||||||
|
///
|
||||||
|
/// [`ReviewEdit::run`] will apply the action to the given [`Patch`].
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum ReviewEdit {
|
||||||
|
/// The initial version of editing a review.
|
||||||
|
///
|
||||||
|
/// This allowed editing the `summary`, `verdict`, and `labels` of a
|
||||||
|
/// [`Patch`], where the `summary` value was optional.
|
||||||
|
#[serde(rename = "review.edit")]
|
||||||
|
V1(ReviewEditV1),
|
||||||
|
/// The latest version of editing a review.
|
||||||
|
///
|
||||||
|
/// This allows editing the `summary`, `verdict`, `labels` of [`Patch`], and
|
||||||
|
/// introduces `embeds` to the review [`summary`].
|
||||||
|
///
|
||||||
|
/// The `summary` of a [`Review`] is now an edit-history.
|
||||||
|
#[serde(rename = "review.edit.v2")]
|
||||||
|
V2(ReviewEditV2),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReviewEdit {
|
||||||
|
/// Create the latest version of [`ReviewEdit`].
|
||||||
|
pub fn new(
|
||||||
|
review: ReviewId,
|
||||||
|
summary: String,
|
||||||
|
verdict: Option<Verdict>,
|
||||||
|
labels: Vec<Label>,
|
||||||
|
embeds: Vec<Embed<Uri>>,
|
||||||
|
) -> Self {
|
||||||
|
Self::V2(ReviewEditV2 {
|
||||||
|
review,
|
||||||
|
summary,
|
||||||
|
verdict,
|
||||||
|
labels,
|
||||||
|
embeds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the [`ReviewId`] that this edit is applying to.
|
||||||
|
pub fn review_id(&self) -> &ReviewId {
|
||||||
|
match self {
|
||||||
|
ReviewEdit::V1(ReviewEditV1 { review, .. }) => review,
|
||||||
|
ReviewEdit::V2(ReviewEditV2 { review, .. }) => review,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the action to the given [`Patch`].
|
||||||
|
pub fn run(
|
||||||
|
self,
|
||||||
|
author: ActorId,
|
||||||
|
timestamp: Timestamp,
|
||||||
|
patch: &mut Patch,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
match self {
|
||||||
|
ReviewEdit::V1(ReviewEditV1 {
|
||||||
|
review,
|
||||||
|
summary,
|
||||||
|
verdict,
|
||||||
|
labels,
|
||||||
|
}) => {
|
||||||
|
if summary.is_none() && verdict.is_none() {
|
||||||
|
return Err(Error::EmptyReview);
|
||||||
|
}
|
||||||
|
let Some(review) = lookup::review_mut(patch, &review)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(body) = summary {
|
||||||
|
review
|
||||||
|
.summary
|
||||||
|
.push(Edit::new(author, body, timestamp, vec![]));
|
||||||
|
}
|
||||||
|
review.verdict = verdict;
|
||||||
|
review.labels = labels;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
ReviewEdit::V2(ReviewEditV2 {
|
||||||
|
review,
|
||||||
|
summary,
|
||||||
|
verdict,
|
||||||
|
labels,
|
||||||
|
embeds,
|
||||||
|
}) => {
|
||||||
|
if summary.is_empty() && verdict.is_none() {
|
||||||
|
return Err(Error::EmptyReview);
|
||||||
|
}
|
||||||
|
let Some(review) = lookup::review_mut(patch, &review)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
review
|
||||||
|
.summary
|
||||||
|
.push(Edit::new(author, summary, timestamp, embeds));
|
||||||
|
review.verdict = verdict;
|
||||||
|
review.labels = labels;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReviewEditV2 {
|
||||||
|
review: ReviewId,
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
summary: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
verdict: Option<Verdict>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
labels: Vec<Label>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
embeds: Vec<Embed<Uri>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReviewEditV1 {
|
||||||
|
review: ReviewId,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
summary: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
verdict: Option<Verdict>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
labels: Vec<Label>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::patch;
|
||||||
|
|
||||||
|
use super::ReviewEdit;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_review_edit() {
|
||||||
|
let v1 = json!({
|
||||||
|
"type": "review.edit",
|
||||||
|
"review": "89d45fb371eb2622ba88188d474347cc526d80bb",
|
||||||
|
"summary": "lgtm",
|
||||||
|
"verdict": "accept",
|
||||||
|
"labels": [],
|
||||||
|
});
|
||||||
|
let v2 = json!({
|
||||||
|
"type": "review.edit.v2",
|
||||||
|
"review": "89d45fb371eb2622ba88188d474347cc526d80bb",
|
||||||
|
"summary": "lgtm",
|
||||||
|
"verdict": "accept",
|
||||||
|
"labels": [],
|
||||||
|
"embeds": [],
|
||||||
|
});
|
||||||
|
serde_json::from_value::<ReviewEdit>(v1.clone()).unwrap();
|
||||||
|
serde_json::from_value::<ReviewEdit>(v2.clone()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
serde_json::from_value::<patch::Action>(v1).unwrap(),
|
||||||
|
patch::Action::ReviewEdit { .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
serde_json::from_value::<patch::Action>(v2).unwrap(),
|
||||||
|
patch::Action::ReviewEdit { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
//! Home for separating serialization and deserialization of [`Patch`] data from
|
||||||
|
//! the final [`Patch`] state.
|
||||||
|
//!
|
||||||
|
//! [`Patch`]: super::Patch
|
||||||
|
|
||||||
|
pub mod review;
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
//! Representation of a [`patch::Review`] for deserializing.
|
||||||
|
//!
|
||||||
|
//! The [`Review`] type contains fields that are:
|
||||||
|
//!
|
||||||
|
//! - Shared within previous versions, e.g. V1 fields that are shared with V2
|
||||||
|
//! - Introduced at a later version, but can use a [`Default`] instance
|
||||||
|
//! - Can be migrated from one version to another
|
||||||
|
//!
|
||||||
|
//! [`patch::Review`]: crate::cob::patch::Review
|
||||||
|
|
||||||
|
use nonempty::NonEmpty;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_untagged::UntaggedEnumVisitor;
|
||||||
|
|
||||||
|
use crate::cob::patch;
|
||||||
|
use crate::cob::patch::{
|
||||||
|
Author, CodeLocation, Comment, Edit, Label, Reactions, ReviewId, Thread, Timestamp, Verdict,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The encoding for a `patch::Review` that can be deserialized and migrated.
|
||||||
|
///
|
||||||
|
/// To maintain backwards-compatibility, [`Review`] must implement:
|
||||||
|
/// ```rust, no_run
|
||||||
|
/// From<Review> for patch::Review
|
||||||
|
/// ```
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(in crate::cob::patch) struct Review {
|
||||||
|
// V1 fields
|
||||||
|
id: ReviewId,
|
||||||
|
author: Author,
|
||||||
|
verdict: Option<Verdict>,
|
||||||
|
comments: Thread<Comment<CodeLocation>>,
|
||||||
|
labels: Vec<Label>,
|
||||||
|
timestamp: Timestamp,
|
||||||
|
|
||||||
|
// V2 fields
|
||||||
|
#[serde(default)]
|
||||||
|
reactions: Reactions,
|
||||||
|
|
||||||
|
// V1 -> V2 conversion
|
||||||
|
#[serde(default)]
|
||||||
|
summary: Summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`Summary`] type represents the different versions of the `summary`
|
||||||
|
/// field of a [`Review`].
|
||||||
|
///
|
||||||
|
/// The `V1` variant holds an `Option<String>`, which can be converted into an
|
||||||
|
/// `Edit` given the `ActorId` and `Timestamp` – supplying an empty `Vec` of
|
||||||
|
/// `Embed`s.
|
||||||
|
///
|
||||||
|
/// The `V2` variant holds the current type `NonEmpty<Edit>`.
|
||||||
|
///
|
||||||
|
/// Using [`Summary::into_edits`], we can get the latest representation of a
|
||||||
|
/// [`patch::Review::summary`].
|
||||||
|
///
|
||||||
|
/// [`patch::Review::summary`]: crate::cob::patch::Review::summary
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(in crate::cob::patch) enum Summary {
|
||||||
|
V2(NonEmpty<Edit>),
|
||||||
|
V1(Option<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Summary {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
UntaggedEnumVisitor::new()
|
||||||
|
.unit(|| Ok(Self::V1(None)))
|
||||||
|
.string(|body| Ok(Self::V1(Some(body.to_owned()))))
|
||||||
|
.seq(|edits| edits.deserialize().map(Self::V2))
|
||||||
|
.deserialize(deserializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Summary {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::V1(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Summary {
|
||||||
|
fn into_edits(self, author: &Author, timestamp: &Timestamp) -> NonEmpty<Edit> {
|
||||||
|
match self {
|
||||||
|
Summary::V1(summary) => NonEmpty::new(Edit::new(
|
||||||
|
*author.public_key(),
|
||||||
|
summary.unwrap_or_default(),
|
||||||
|
*timestamp,
|
||||||
|
vec![],
|
||||||
|
)),
|
||||||
|
Summary::V2(edits) => edits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Review> for patch::Review {
|
||||||
|
fn from(review: Review) -> Self {
|
||||||
|
let Review {
|
||||||
|
id,
|
||||||
|
author,
|
||||||
|
verdict,
|
||||||
|
comments,
|
||||||
|
labels,
|
||||||
|
timestamp,
|
||||||
|
reactions,
|
||||||
|
summary,
|
||||||
|
} = review;
|
||||||
|
let summary = summary.into_edits(&author, ×tamp);
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
author,
|
||||||
|
verdict,
|
||||||
|
summary,
|
||||||
|
comments,
|
||||||
|
labels,
|
||||||
|
reactions,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use nonempty::nonempty;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
cob::{thread::Edit, Timestamp},
|
||||||
|
patch,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Review, Summary};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_review_summary() {
|
||||||
|
let summary_null = json!(null);
|
||||||
|
let summary_string = json!("lgtm");
|
||||||
|
let summary_edits = json!([{
|
||||||
|
"author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
|
||||||
|
"timestamp": 1710947885000_i64,
|
||||||
|
"body": "lgtm",
|
||||||
|
"embeds": [],
|
||||||
|
}]);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<Summary>(summary_null).unwrap(),
|
||||||
|
Summary::V1(None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<Summary>(summary_string).unwrap(),
|
||||||
|
Summary::V1(Some("lgtm".to_string()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<Summary>(summary_edits).unwrap(),
|
||||||
|
Summary::V2(nonempty![Edit::new(
|
||||||
|
"z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
"lgtm".to_string(),
|
||||||
|
Timestamp::from_secs(1710947885),
|
||||||
|
vec![]
|
||||||
|
)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_review_deserialize_summary_migration_null_summary() {
|
||||||
|
let review = json!({
|
||||||
|
"id": "89d45fb371eb2622ba88188d474347cc526d80bb",
|
||||||
|
"author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
|
||||||
|
"verdict": "accept",
|
||||||
|
"summary": null,
|
||||||
|
"comments": {
|
||||||
|
"comments": {},
|
||||||
|
"timeline": []
|
||||||
|
},
|
||||||
|
"labels": [],
|
||||||
|
"timestamp": 1710947885000_i64
|
||||||
|
});
|
||||||
|
let v1 = serde_json::from_value::<Review>(review.clone()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<patch::Review>(review).unwrap(),
|
||||||
|
v1.into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_review_deserialize_summary_migration_without_summary() {
|
||||||
|
let review = json!({
|
||||||
|
"id": "89d45fb371eb2622ba88188d474347cc526d80bb",
|
||||||
|
"author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
|
||||||
|
"verdict": "accept",
|
||||||
|
"comments": {
|
||||||
|
"comments": {},
|
||||||
|
"timeline": []
|
||||||
|
},
|
||||||
|
"labels": [],
|
||||||
|
"timestamp": 1710947885000_i64
|
||||||
|
});
|
||||||
|
let v1 = serde_json::from_value::<Review>(review.clone()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<patch::Review>(review).unwrap(),
|
||||||
|
v1.into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_review_deserialize_summary_migration_with_summary() {
|
||||||
|
let review = json!({
|
||||||
|
"id": "89d45fb371eb2622ba88188d474347cc526d80bb",
|
||||||
|
"author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
|
||||||
|
"verdict": "accept",
|
||||||
|
"summary": "lgtm",
|
||||||
|
"comments": {
|
||||||
|
"comments": {},
|
||||||
|
"timeline": []
|
||||||
|
},
|
||||||
|
"labels": [],
|
||||||
|
"timestamp": 1710947885000_i64
|
||||||
|
});
|
||||||
|
let v1 = serde_json::from_value::<Review>(review.clone()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<patch::Review>(review).unwrap(),
|
||||||
|
v1.into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_review_deserialize_summary_v2() {
|
||||||
|
let review = json!({
|
||||||
|
"id": "89d45fb371eb2622ba88188d474347cc526d80bb",
|
||||||
|
"author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
|
||||||
|
"verdict": "accept",
|
||||||
|
"summary": [{
|
||||||
|
"author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
|
||||||
|
"timestamp": 1710947885000_i64,
|
||||||
|
"body": "lgtm",
|
||||||
|
"embeds": [],
|
||||||
|
}],
|
||||||
|
"comments": {
|
||||||
|
"comments": {},
|
||||||
|
"timeline": []
|
||||||
|
},
|
||||||
|
"labels": [],
|
||||||
|
"timestamp": 1710947885000_i64
|
||||||
|
});
|
||||||
|
let v2 = serde_json::from_value::<Review>(review.clone()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<patch::Review>(review).unwrap(),
|
||||||
|
v2.into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue