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:
Fintan Halpenny 2025-07-01 16:52:21 +02:00 committed by Erik Kundt
parent c30298fb8a
commit 34939253f7
6 changed files with 573 additions and 45 deletions

28
Cargo.lock generated
View File

@ -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"

View File

@ -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 }

View File

@ -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]

View File

@ -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 { .. }
));
}
}

View File

@ -0,0 +1,6 @@
//! Home for separating serialization and deserialization of [`Patch`] data from
//! the final [`Patch`] state.
//!
//! [`Patch`]: super::Patch
pub mod review;

View File

@ -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, &timestamp);
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()
);
}
}