radicle: Fix patch COB `review` action
When two `review` actions were posted by the same author, for the same revision, an error was returned, cancelling evaluation for that author. This caused certain patches to for eg. lose their merge status. To remedy this, we *allow* multiple `review` actions per revision, but simply take the last one as the "current" review. Since this makes the `review.edit` action redundant, we remove that action completely. This is safe, as none of the tools created review edits.
This commit is contained in:
parent
8922388caa
commit
757483f351
|
|
@ -1,6 +1,5 @@
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
|
|
||||||
use std::collections::btree_map::Entry;
|
|
||||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
|
|
@ -186,14 +185,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")]
|
|
||||||
verdict: Option<Verdict>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
summary: Option<String>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "review.redact")]
|
#[serde(rename = "review.redact")]
|
||||||
ReviewRedact { review: ReviewId },
|
ReviewRedact { review: ReviewId },
|
||||||
#[serde(rename = "review.comment")]
|
#[serde(rename = "review.comment")]
|
||||||
|
|
@ -658,15 +649,6 @@ impl Patch {
|
||||||
},
|
},
|
||||||
// Anyone can submit a review.
|
// Anyone can submit a review.
|
||||||
Action::Review { .. } => Authorization::Allow,
|
Action::Review { .. } => Authorization::Allow,
|
||||||
// The review author can edit a review.
|
|
||||||
Action::ReviewEdit { review, .. } => {
|
|
||||||
if let Some((_, review)) = lookup::review(self, review)? {
|
|
||||||
Authorization::from(actor == review.author.public_key())
|
|
||||||
} else {
|
|
||||||
// Redacted.
|
|
||||||
Authorization::Unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Action::ReviewRedact { 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())
|
||||||
|
|
@ -804,29 +786,6 @@ impl Patch {
|
||||||
return Err(Error::Missing(revision.into_inner()));
|
return Err(Error::Missing(revision.into_inner()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::ReviewEdit {
|
|
||||||
review,
|
|
||||||
summary,
|
|
||||||
verdict,
|
|
||||||
} => {
|
|
||||||
let Some(Some((revision, author))) = self.reviews.get(&review) else {
|
|
||||||
return Err(Error::Missing(review.into_inner()));
|
|
||||||
};
|
|
||||||
let Some(rev) = self.revisions.get_mut(revision) else {
|
|
||||||
return Err(Error::Missing(revision.into_inner()));
|
|
||||||
};
|
|
||||||
// If the revision was redacted concurrently, there's nothing to do.
|
|
||||||
// Likewise, if the review was redacted concurrently, there's nothing to do.
|
|
||||||
if let Some(rev) = rev {
|
|
||||||
let Some(review) = rev.reviews.get_mut(author) else {
|
|
||||||
return Err(Error::Missing(review.into_inner()));
|
|
||||||
};
|
|
||||||
if let Some(review) = review {
|
|
||||||
review.summary = summary;
|
|
||||||
review.verdict = verdict;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Action::Revision {
|
Action::Revision {
|
||||||
description,
|
description,
|
||||||
base,
|
base,
|
||||||
|
|
@ -891,30 +850,25 @@ impl Patch {
|
||||||
labels,
|
labels,
|
||||||
} => {
|
} => {
|
||||||
let Some(rev) = self.revisions.get_mut(&revision) else {
|
let Some(rev) = self.revisions.get_mut(&revision) else {
|
||||||
return Err(Error::Missing(revision.into_inner()));
|
// If the revision was redacted concurrently, there's nothing to do.
|
||||||
|
return Ok(());
|
||||||
};
|
};
|
||||||
if summary.is_none() && verdict.is_none() {
|
if summary.is_none() && verdict.is_none() {
|
||||||
return Err(Error::EmptyReview);
|
return Err(Error::EmptyReview);
|
||||||
}
|
}
|
||||||
if let Some(rev) = rev {
|
if let Some(rev) = rev {
|
||||||
let id = ReviewId(entry);
|
let id = ReviewId(entry);
|
||||||
// Nb. Applying two reviews by the same author is not allowed.
|
let review = Review::new(
|
||||||
match rev.reviews.entry(author) {
|
|
||||||
Entry::Occupied(_) => {
|
|
||||||
return Err(Error::DuplicateReview(id, revision, author));
|
|
||||||
}
|
|
||||||
Entry::Vacant(e) => {
|
|
||||||
e.insert(Some(Review::new(
|
|
||||||
id,
|
id,
|
||||||
Author::new(author),
|
Author::new(author),
|
||||||
verdict,
|
verdict,
|
||||||
summary.to_owned(),
|
summary.to_owned(),
|
||||||
labels,
|
labels,
|
||||||
timestamp,
|
timestamp,
|
||||||
)));
|
);
|
||||||
}
|
rev.reviews.entry(author).or_default().push(review);
|
||||||
}
|
// Update reviews index. The latest review is what matters, the older
|
||||||
// Update reviews index.
|
// ones are kept for history.
|
||||||
self.reviews.insert(id, Some((revision, author)));
|
self.reviews.insert(id, Some((revision, author)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1005,12 +959,13 @@ impl Patch {
|
||||||
let Some(revision) = redactable else {
|
let Some(revision) = redactable else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
// The review must have existed as well.
|
// The reviews may have been removed concurrently.
|
||||||
let Some(review) = revision.reviews.get_mut(reviewer) else {
|
let Some(reviews) = revision.reviews.get_mut(reviewer) else {
|
||||||
return Err(Error::Missing(review.into_inner()));
|
return Ok(());
|
||||||
};
|
};
|
||||||
// Set both the review and the locator in the review index to redacted.
|
// Remove matching reviews for this author (there should only be one).
|
||||||
*review = None;
|
reviews.retain(|r| r.id != review);
|
||||||
|
// Set the review locator in the review index to redacted.
|
||||||
*locator = None;
|
*locator = None;
|
||||||
}
|
}
|
||||||
Action::Merge { revision, commit } => {
|
Action::Merge { revision, commit } => {
|
||||||
|
|
@ -1293,10 +1248,13 @@ mod lookup {
|
||||||
Some(Some((revision, author))) => {
|
Some(Some((revision, author))) => {
|
||||||
match patch.revisions.get(revision) {
|
match patch.revisions.get(revision) {
|
||||||
Some(Some(r)) => {
|
Some(Some(r)) => {
|
||||||
let Some(review) = r.reviews.get(author) else {
|
let Some(reviews) = r.reviews.get(author) else {
|
||||||
return Err(Error::Missing(review.into_inner()));
|
return Err(Error::Missing(review.into_inner()));
|
||||||
};
|
};
|
||||||
Ok(review.as_ref().map(|review| (r, review)))
|
Ok(reviews
|
||||||
|
.iter()
|
||||||
|
.find(|r| &r.id == review)
|
||||||
|
.map(|review| (r, review)))
|
||||||
}
|
}
|
||||||
Some(None) => {
|
Some(None) => {
|
||||||
// If the revision was redacted concurrently, there's nothing to do.
|
// If the revision was redacted concurrently, there's nothing to do.
|
||||||
|
|
@ -1322,10 +1280,10 @@ mod lookup {
|
||||||
Some(Some((revision, author))) => {
|
Some(Some((revision, author))) => {
|
||||||
match patch.revisions.get_mut(revision) {
|
match patch.revisions.get_mut(revision) {
|
||||||
Some(Some(r)) => {
|
Some(Some(r)) => {
|
||||||
let Some(review) = r.reviews.get_mut(author) else {
|
let Some(reviews) = r.reviews.get_mut(author) else {
|
||||||
return Err(Error::Missing(review.into_inner()));
|
return Err(Error::Missing(review.into_inner()));
|
||||||
};
|
};
|
||||||
Ok(review.as_mut())
|
Ok(reviews.iter_mut().find(|r| &r.id == review))
|
||||||
}
|
}
|
||||||
Some(None) => {
|
Some(None) => {
|
||||||
// If the revision was redacted concurrently, there's nothing to do.
|
// If the revision was redacted concurrently, there's nothing to do.
|
||||||
|
|
@ -1360,8 +1318,8 @@ pub struct Revision {
|
||||||
pub(super) oid: git::Oid,
|
pub(super) oid: git::Oid,
|
||||||
/// Discussion around this revision.
|
/// Discussion around this revision.
|
||||||
pub(super) discussion: Thread<Comment<CodeLocation>>,
|
pub(super) discussion: Thread<Comment<CodeLocation>>,
|
||||||
/// Reviews of this revision's changes (one per actor).
|
/// Reviews of this revision's changes (all review edits are kept).
|
||||||
pub(super) reviews: BTreeMap<ActorId, Option<Review>>,
|
pub(super) reviews: BTreeMap<ActorId, Vec<Review>>,
|
||||||
/// When this revision was created.
|
/// When this revision was created.
|
||||||
pub(super) timestamp: Timestamp,
|
pub(super) timestamp: Timestamp,
|
||||||
/// Review comments resolved by this revision.
|
/// Review comments resolved by this revision.
|
||||||
|
|
@ -1459,12 +1417,20 @@ impl Revision {
|
||||||
pub fn reviews(&self) -> impl DoubleEndedIterator<Item = (&PublicKey, &Review)> {
|
pub fn reviews(&self) -> impl DoubleEndedIterator<Item = (&PublicKey, &Review)> {
|
||||||
self.reviews
|
self.reviews
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(author, review)| review.as_ref().map(|r| (author, r)))
|
.filter_map(|(author, review)| review.last().map(|r| (author, r)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a review by author.
|
/// Get a review by author.
|
||||||
pub fn review_by(&self, author: &ActorId) -> Option<&Review> {
|
pub fn review_by(&self, author: &ActorId) -> Option<&Review> {
|
||||||
self.reviews.get(author).and_then(|o| o.as_ref())
|
self.reviews.get(author).and_then(|o| o.last())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the chronological review history of an author.
|
||||||
|
pub fn review_history_by(&self, author: &ActorId) -> &[Review] {
|
||||||
|
self.reviews
|
||||||
|
.get(author)
|
||||||
|
.map(|r| r.as_slice())
|
||||||
|
.unwrap_or(&[])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1679,19 +1645,6 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn edit_review(
|
|
||||||
&mut self,
|
|
||||||
review: ReviewId,
|
|
||||||
summary: Option<String>,
|
|
||||||
verdict: Option<Verdict>,
|
|
||||||
) -> Result<(), store::Error> {
|
|
||||||
self.push(Action::ReviewEdit {
|
|
||||||
review,
|
|
||||||
summary,
|
|
||||||
verdict,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Redact the revision.
|
/// Redact the revision.
|
||||||
pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> {
|
pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> {
|
||||||
self.push(Action::RevisionRedact { revision })
|
self.push(Action::RevisionRedact { revision })
|
||||||
|
|
@ -1877,6 +1830,7 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Review a patch revision.
|
/// Review a patch revision.
|
||||||
|
/// Overwrites an existing review for that revision, if any.
|
||||||
pub fn review(
|
pub fn review(
|
||||||
&mut self,
|
&mut self,
|
||||||
revision: RevisionId,
|
revision: RevisionId,
|
||||||
|
|
@ -2019,19 +1973,6 @@ where
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Edit review.
|
|
||||||
pub fn edit_review<G: Signer>(
|
|
||||||
&mut self,
|
|
||||||
review: ReviewId,
|
|
||||||
summary: Option<String>,
|
|
||||||
verdict: Option<Verdict>,
|
|
||||||
signer: &G,
|
|
||||||
) -> Result<EntryId, Error> {
|
|
||||||
self.transaction("Edit review", signer, |tx| {
|
|
||||||
tx.edit_review(review, summary, verdict)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Redact a revision.
|
/// Redact a revision.
|
||||||
pub fn redact<G: Signer>(
|
pub fn redact<G: Signer>(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -3149,7 +3090,7 @@ mod test {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (rid, _) = patch.latest();
|
let (rid, _) = patch.latest();
|
||||||
let review = patch
|
patch
|
||||||
.review(
|
.review(
|
||||||
rid,
|
rid,
|
||||||
Some(Verdict::Accept),
|
Some(Verdict::Accept),
|
||||||
|
|
@ -3159,14 +3100,15 @@ mod test {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
patch
|
patch
|
||||||
.edit_review(
|
.review(
|
||||||
review,
|
rid,
|
||||||
Some("Whoops!".to_owned()),
|
|
||||||
Some(Verdict::Reject),
|
Some(Verdict::Reject),
|
||||||
|
Some("Whoops!".to_owned()),
|
||||||
|
vec![],
|
||||||
&alice.signer,
|
&alice.signer,
|
||||||
)
|
)
|
||||||
.unwrap(); // Overwrite the comment.
|
.unwrap(); // Overwrite the comment.
|
||||||
//
|
|
||||||
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));
|
||||||
|
|
@ -3239,11 +3181,17 @@ mod test {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (rid, _) = patch.latest();
|
let (rid, _) = patch.latest();
|
||||||
let review = patch
|
patch
|
||||||
.review(rid, None, Some("Nah".to_owned()), vec![], &alice.signer)
|
.review(
|
||||||
|
rid,
|
||||||
|
Some(Verdict::Accept),
|
||||||
|
Some("Nah".to_owned()),
|
||||||
|
vec![],
|
||||||
|
&alice.signer,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
patch
|
patch
|
||||||
.edit_review(review, None, None, &alice.signer)
|
.review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let id = patch.id;
|
let id = patch.id;
|
||||||
|
|
@ -3251,6 +3199,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::Accept));
|
||||||
assert_eq!(review.summary(), None);
|
assert_eq!(review.summary(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue