pub mod cache;
mod actions;
pub use actions::ReviewEdit;
mod encoding;
use std::collections::btree_map;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt;
use std::ops::Deref;
use std::str::FromStr;
use std::sync::LazyLock;
use amplify::Wrapper;
use nonempty::NonEmpty;
use serde::{Deserialize, Serialize};
use storage::{HasRepoId, RepositoryError};
use thiserror::Error;
use crate::cob;
use crate::cob::common::{Author, Authorization, CodeLocation, Label, Reaction, Timestamp};
use crate::cob::store::Transaction;
use crate::cob::store::access::WriteAs;
use crate::cob::store::{Cob, CobAction};
use crate::cob::thread;
use crate::cob::thread::Thread;
use crate::cob::thread::{Comment, CommentId, Edit, Reactions};
use crate::cob::{ActorId, Embed, EntryId, ObjectId, TypeName, Uri, op, store};
use crate::crypto::PublicKey;
use crate::git;
use crate::identity::PayloadError;
use crate::identity::doc::{DocAt, DocError};
use crate::prelude::*;
use crate::storage;
pub use cache::Cache;
/// Type name of a patch.
pub static TYPENAME: LazyLock<TypeName> =
LazyLock::new(|| FromStr::from_str("xyz.radicle.patch").expect("type name is valid"));
/// Patch operation.
pub type Op = cob::Op<Action>;
/// Identifier for a patch.
pub type PatchId = ObjectId;
pub type PatchStream<'a> = cob::stream::Stream<'a, Action>;
impl<'a> PatchStream<'a> {
pub fn init(patch: PatchId, store: &'a storage::git::Repository) -> Self {
let history = cob::stream::CobRange::new(&TYPENAME, &patch);
Self::new(&store.backend, history, TYPENAME.clone())
}
}
/// Unique identifier for a patch revision.
#[derive(
Wrapper,
Debug,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
From,
Display,
)]
#[display(inner)]
#[wrap(Deref)]
pub struct RevisionId(EntryId);
/// Unique identifier for a patch review.
#[derive(
Wrapper,
Debug,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
From,
Display,
)]
#[display(inner)]
#[wrapper(Deref)]
pub struct ReviewId(EntryId);
/// Index of a revision in the revisions list.
pub type RevisionIx = usize;
/// Error applying an operation onto a state.
#[derive(Debug, Error)]
pub enum Error {
/// Causal dependency missing.
///
/// This error indicates that the operations are not being applied
/// in causal order, which is a requirement for this CRDT.
///
/// For example, this can occur if an operation references another operation
/// that hasn't happened yet.
#[error("causal dependency {0:?} missing")]
Missing(EntryId),
/// Error applying an op to the patch thread.
#[error("thread apply failed: {0}")]
Thread(#[from] thread::Error),
/// Error loading the identity document committed to by an operation.
#[error("identity doc failed to load: {0}")]
Doc(#[from] DocError),
/// Identity document is missing.
#[error("missing identity document")]
MissingIdentity,
/// Review is empty.
#[error("empty review; verdict or summary not provided")]
EmptyReview,
/// Duplicate review.
#[error("review {0} of {1} already exists by author {2}")]
DuplicateReview(ReviewId, RevisionId, NodeId),
/// Error loading the document payload.
#[error("payload failed to load: {0}")]
Payload(#[from] PayloadError),
/// Git error.
#[error("git: {0}")]
Git(#[from] git::raw::Error),
/// Store error.
#[error("store: {0}")]
Store(#[from] store::Error),
#[error("op decoding failed: {0}")]
Op(#[from] op::OpEncodingError),
/// Action not authorized by the author
#[error("{0} not authorized to apply {1:?}")]
NotAuthorized(ActorId, Box<Action>),
/// An illegal action.
#[error("action is not allowed: {0}")]
NotAllowed(EntryId),
/// Revision not found.
#[error("revision not found: {0}")]
RevisionNotFound(RevisionId),
/// Initialization failed.
#[error("initialization failed: {0}")]
Init(&'static str),
#[error("failed to update patch {id} in cache: {err}")]
CacheUpdate {
id: PatchId,
#[source]
err: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("failed to remove patch {id} from cache: {err}")]
CacheRemove {
id: PatchId,
#[source]
err: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("failed to remove patches from cache: {err}")]
CacheRemoveAll {
#[source]
err: Box<dyn std::error::Error + Send + Sync + 'static>,
},
}
/// Patch operation.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Action {
//
// Actions on patch.
//
#[serde(rename = "edit")]
Edit {
title: cob::Title,
target: MergeTarget,
},
#[serde(rename = "label")]
Label { labels: BTreeSet<Label> },
#[serde(rename = "lifecycle")]
Lifecycle { state: Lifecycle },
#[serde(rename = "assign")]
Assign { assignees: BTreeSet<Did> },
#[serde(rename = "merge")]
Merge {
revision: RevisionId,
commit: git::Oid,
},
//
// Review actions
//
#[serde(rename = "review")]
Review {
revision: RevisionId,
#[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")]
ReviewRedact { review: ReviewId },
#[serde(rename = "review.comment")]
ReviewComment {
review: ReviewId,
body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
location: Option<CodeLocation>,
/// Comment this is a reply to.
/// Should be [`None`] if it's the first comment.
/// Should be [`Some`] otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
reply_to: Option<CommentId>,
/// Embedded content.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
embeds: Vec<Embed<Uri>>,
},
#[serde(rename = "review.comment.edit")]
ReviewCommentEdit {
review: ReviewId,
comment: EntryId,
body: String,
embeds: Vec<Embed<Uri>>,
},
#[serde(rename = "review.comment.redact")]
ReviewCommentRedact { review: ReviewId, comment: EntryId },
#[serde(rename = "review.comment.react")]
ReviewCommentReact {
review: ReviewId,
comment: EntryId,
reaction: Reaction,
active: bool,
},
#[serde(rename = "review.comment.resolve")]
ReviewCommentResolve { review: ReviewId, comment: EntryId },
#[serde(rename = "review.comment.unresolve")]
ReviewCommentUnresolve { review: ReviewId, comment: EntryId },
/// React to the review.
#[serde(rename = "review.react")]
ReviewReact {
review: ReviewId,
reaction: Reaction,
active: bool,
},
//
// Revision actions
//
#[serde(rename = "revision")]
Revision {
description: String,
base: git::Oid,
oid: git::Oid,
/// Review comments resolved by this revision.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
resolves: BTreeSet<(EntryId, CommentId)>,
},
#[serde(rename = "revision.edit")]
RevisionEdit {
revision: RevisionId,
description: String,
/// Embedded content.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
embeds: Vec<Embed<Uri>>,
},
/// React to the revision.
#[serde(rename = "revision.react")]
RevisionReact {
revision: RevisionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
location: Option<CodeLocation>,
reaction: Reaction,
active: bool,
},
#[serde(rename = "revision.redact")]
RevisionRedact { revision: RevisionId },
#[serde(rename_all = "camelCase")]
#[serde(rename = "revision.comment")]
RevisionComment {
/// The revision to comment on.
revision: RevisionId,
/// For comments on the revision code.
#[serde(default, skip_serializing_if = "Option::is_none")]
location: Option<CodeLocation>,
/// Comment body.
body: String,
/// Comment this is a reply to.
/// Should be [`None`] if it's the top-level comment.
/// Should be the root [`CommentId`] if it's a top-level comment.
#[serde(default, skip_serializing_if = "Option::is_none")]
reply_to: Option<CommentId>,
/// Embedded content.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
embeds: Vec<Embed<Uri>>,
},
/// Edit a revision comment.
#[serde(rename = "revision.comment.edit")]
RevisionCommentEdit {
revision: RevisionId,
comment: CommentId,
body: String,
embeds: Vec<Embed<Uri>>,
},
/// Redact a revision comment.
#[serde(rename = "revision.comment.redact")]
RevisionCommentRedact {
revision: RevisionId,
comment: CommentId,
},
/// React to a revision comment.
#[serde(rename = "revision.comment.react")]
RevisionCommentReact {
revision: RevisionId,
comment: CommentId,
reaction: Reaction,
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 {
fn parents(&self) -> Vec<git::Oid> {
match self {
Self::Revision { base, oid, .. } => {
vec![*base, *oid]
}
Self::Merge { commit, .. } => {
vec![*commit]
}
_ => vec![],
}
}
fn produces_identifier(&self) -> bool {
matches!(
self,
Self::Revision { .. }
| Self::RevisionComment { .. }
| Self::Review { .. }
| Self::ReviewComment { .. }
)
}
}
/// Output of a merge.
#[derive(Debug)]
#[must_use]
pub struct Merged<'a, R> {
pub patch: PatchId,
pub entry: EntryId,
stored: &'a R,
}
impl<R: WriteRepository> Merged<'_, R> {
/// Cleanup after merging a patch.
///
/// This removes Git refs relating to the patch, both in the working copy,
/// and the stored copy; and updates `rad/sigrefs`.
pub fn cleanup<Signer>(
self,
working: &git::raw::Repository,
signer: &Signer,
) -> Result<(), storage::RepositoryError>
where
Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
Signer: crypto::signature::Signer<crypto::Signature>,
Signer: crypto::signature::Verifier<crypto::Signature>,
{
let nid = &signer.verifying_key();
let stored_ref = git::refs::patch(&self.patch).with_namespace(nid.into());
let working_ref = git::refs::workdir::patch_upstream(&self.patch);
working
.find_reference(&working_ref)
.and_then(|mut r| r.delete())
.ok();
self.stored
.raw()
.find_reference(&stored_ref)
.and_then(|mut r| r.delete())
.ok();
self.stored.sign_refs(signer)?;
Ok(())
}
}
/// Where a patch is intended to be merged.
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MergeTarget {
/// Intended for the default branch of the project delegates.
/// Note that if the delegations change while the patch is open,
/// this will always mean whatever the "current" delegation set is.
/// If it were otherwise, patches could become un-mergeable.
#[default]
Delegates,
}
impl MergeTarget {
/// Get the head of the target branch.
pub fn head<R: ReadRepository>(&self, repo: &R) -> Result<git::Oid, RepositoryError> {
match self {
MergeTarget::Delegates => {
let (_, target) = repo.head()?;
Ok(target)
}
}
}
}
/// Patch state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Patch {
/// Title of the patch.
pub(super) title: cob::Title,
/// Patch author.
pub(super) author: Author,
/// Current state of the patch.
pub(super) state: State,
/// Target this patch is meant to be merged in.
pub(super) target: MergeTarget,
/// Associated labels.
/// Labels can be added and removed at will.
pub(super) labels: BTreeSet<Label>,
/// Patch merges.
///
/// Only one merge is allowed per user.
///
/// Merges can be removed and replaced, but not modified. Generally, once a revision is merged,
/// it stays that way. Being able to remove merges may be useful in case of force updates
/// on the target branch.
pub(super) merges: BTreeMap<ActorId, Merge>,
/// List of patch revisions. The initial changeset is part of the
/// first revision.
///
/// Revisions can be redacted, but are otherwise immutable.
pub(super) revisions: BTreeMap<RevisionId, Option<Revision>>,
/// Users assigned to review this patch.
pub(super) assignees: BTreeSet<ActorId>,
/// Timeline of operations.
pub(super) timeline: Vec<EntryId>,
/// Reviews index. Keeps track of reviews for better performance.
pub(super) reviews: BTreeMap<ReviewId, Option<(RevisionId, ActorId)>>,
}
impl Patch {
/// Construct a new patch object from a revision.
pub fn new(
title: cob::Title,
target: MergeTarget,
(id, revision): (RevisionId, Revision),
) -> Self {
Self {
title,
author: revision.author.clone(),
state: State::default(),
target,
labels: BTreeSet::default(),
merges: BTreeMap::default(),
revisions: BTreeMap::from_iter([(id, Some(revision))]),
assignees: BTreeSet::default(),
timeline: vec![id.into_inner()],
reviews: BTreeMap::default(),
}
}
/// Title of the patch.
pub fn title(&self) -> &str {
self.title.as_ref()
}
/// Current state of the patch.
pub fn state(&self) -> &State {
&self.state
}
/// Target this patch is meant to be merged in.
pub fn target(&self) -> MergeTarget {
self.target
}
/// Timestamp of the first revision of the patch.
pub fn timestamp(&self) -> Timestamp {
self.updates()
.next()
.map(|(_, r)| r)
.expect("Patch::timestamp: at least one revision is present")
.timestamp
}
/// Associated labels.
pub fn labels(&self) -> impl Iterator<Item = &Label> {
self.labels.iter()
}
/// Patch description.
pub fn description(&self) -> &str {
let (_, r) = self.root();
r.description()
}
/// Patch embeds.
pub fn embeds(&self) -> &[Embed<Uri>] {
let (_, r) = self.root();
r.embeds()
}
/// Author of the first revision of the patch.
pub fn author(&self) -> &Author {
&self.author
}
/// All revision authors.
pub fn authors(&self) -> BTreeSet<&Author> {
self.revisions
.values()
.filter_map(|r| r.as_ref())
.map(|r| &r.author)
.collect()
}
/// Get the `Revision` by its `RevisionId`.
///
/// None is returned if the `Revision` has been redacted (deleted).
pub fn revision(&self, id: &RevisionId) -> Option<&Revision> {
self.revisions.get(id).and_then(|o| o.as_ref())
}
/// List of patch revisions by the patch author. The initial changeset is part of the
/// first revision.
pub fn updates(&self) -> impl DoubleEndedIterator<Item = (RevisionId, &Revision)> {
self.revisions_by(self.author().public_key())
}
/// List of all patch revisions by all authors.
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (RevisionId, &Revision)> {
self.timeline.iter().filter_map(move |id| {
self.revisions
.get(id)
.and_then(|o| o.as_ref())
.map(|rev| (RevisionId(*id), rev))
})
}
/// List of patch revisions by the given author.
pub fn revisions_by<'a>(
&'a self,
author: &'a PublicKey,
) -> impl DoubleEndedIterator<Item = (RevisionId, &'a Revision)> {
self.revisions()
.filter(move |(_, r)| r.author.public_key() == author)
}
/// List of patch reviews of the given revision.
pub fn reviews_of(&self, rev: RevisionId) -> impl Iterator<Item = (&ReviewId, &Review)> {
self.reviews.iter().filter_map(move |(review_id, t)| {
t.and_then(|(rev_id, pk)| {
if rev == rev_id {
self.revision(&rev_id)
.and_then(|r| r.review_by(&pk))
.map(|r| (review_id, r))
} else {
None
}
})
})
}
/// List of patch assignees.
pub fn assignees(&self) -> impl Iterator<Item = Did> + '_ {
self.assignees.iter().map(Did::from)
}
/// Get the merges.
pub fn merges(&self) -> impl Iterator<Item = (&ActorId, &Merge)> {
self.merges.iter()
}
/// Reference to the Git object containing the code on the latest revision.
pub fn head(&self) -> &git::Oid {
&self.latest().1.oid
}
/// Get the commit of the target branch on which this patch is based.
/// This can change via a patch update.
pub fn base(&self) -> &git::Oid {
&self.latest().1.base
}
/// Get the merge base of this patch.
pub fn merge_base<R: ReadRepository>(
&self,
repo: &R,
) -> Result<crate::git::Oid, crate::git::raw::Error> {
repo.merge_base(self.base(), self.head())
}
/// Get the commit range of this patch.
pub fn range(&self) -> Result<(crate::git::Oid, crate::git::Oid), crate::git::raw::Error> {
Ok((*self.base(), *self.head()))
}
/// Index of latest revision in the revisions list.
pub fn version(&self) -> RevisionIx {
self.revisions
.len()
.checked_sub(1)
.expect("Patch::version: at least one revision is present")
}
/// Root revision.
///
/// This is the revision that was created with the patch.
pub fn root(&self) -> (RevisionId, &Revision) {
self.updates()
.next()
.expect("Patch::root: there is always a root revision")
}
/// Latest revision by the patch author.
pub fn latest(&self) -> (RevisionId, &Revision) {
self.latest_by(self.author().public_key())
.expect("Patch::latest: there is always at least one revision")
}
/// Latest revision by the given author.
pub fn latest_by<'a>(&'a self, author: &'a PublicKey) -> Option<(RevisionId, &'a Revision)> {
self.revisions_by(author).next_back()
}
/// Time of last update.
pub fn updated_at(&self) -> Timestamp {
self.latest().1.timestamp()
}
/// Check if the patch is merged.
pub fn is_merged(&self) -> bool {
matches!(self.state(), State::Merged { .. })
}
/// Check if the patch is open.
pub fn is_open(&self) -> bool {
matches!(self.state(), State::Open { .. })
}
/// Check if the patch is archived.
pub fn is_archived(&self) -> bool {
matches!(self.state(), State::Archived)
}
/// Check if the patch is a draft.
pub fn is_draft(&self) -> bool {
matches!(self.state(), State::Draft)
}
/// Apply authorization rules on patch actions.
pub fn authorization(
&self,
action: &Action,
actor: &ActorId,
doc: &Doc,
) -> Result<Authorization, Error> {
if doc.is_delegate(&actor.into()) {
// A delegate is authorized to do all actions.
return Ok(Authorization::Allow);
}
let author = self.author().id().as_key();
let outcome = match action {
// The patch author can edit the patch and change its state.
Action::Edit { .. } => Authorization::from(actor == author),
Action::Lifecycle { state } => Authorization::from(match state {
Lifecycle::Open => actor == author,
Lifecycle::Draft => actor == author,
Lifecycle::Archived => actor == author,
}),
// Only delegates can carry out these actions.
Action::Label { labels } => {
if labels == &self.labels {
// No-op is allowed for backwards compatibility.
Authorization::Allow
} else {
Authorization::Deny
}
}
Action::Assign { .. } => Authorization::Deny,
Action::Merge { .. } => match self.target() {
MergeTarget::Delegates => Authorization::Deny,
},
// Anyone can submit a review.
Action::Review { .. } => Authorization::Allow,
Action::ReviewRedact { review, .. } => {
if let Some((_, review)) = lookup::review(self, review)? {
Authorization::from(actor == review.author.public_key())
} else {
// Redacted.
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.
Action::ReviewComment { .. } => Authorization::Allow,
// The comment author can edit and redact their own comment.
Action::ReviewCommentEdit {
review, comment, ..
}
| Action::ReviewCommentRedact { review, comment } => {
if let Some((_, review)) = lookup::review(self, review)? {
if let Some(comment) = review.comments.comment(comment) {
return Ok(Authorization::from(*actor == comment.author()));
}
}
// Redacted.
Authorization::Unknown
}
// Anyone can react to a review comment.
Action::ReviewCommentReact { .. } => Authorization::Allow,
// The reviewer, commenter or revision author can resolve and unresolve review comments.
Action::ReviewCommentResolve { review, comment }
| Action::ReviewCommentUnresolve { review, comment } => {
if let Some((revision, review)) = lookup::review(self, review)? {
if let Some(comment) = review.comments.comment(comment) {
return Ok(Authorization::from(
actor == &comment.author()
|| actor == review.author.public_key()
|| actor == revision.author.public_key(),
));
}
}
// Redacted.
Authorization::Unknown
}
Action::ReviewReact { .. } => Authorization::Allow,
// Anyone can propose revisions.
Action::Revision { .. } => Authorization::Allow,
// Only the revision author can edit or redact their revision.
Action::RevisionEdit { revision, .. } | Action::RevisionRedact { revision, .. } => {
if let Some(revision) = lookup::revision(self, revision)? {
Authorization::from(actor == revision.author.public_key())
} else {
// Redacted.
Authorization::Unknown
}
}
// Anyone can react to or comment on a revision.
Action::RevisionReact { .. } => Authorization::Allow,
Action::RevisionComment { .. } => Authorization::Allow,
// Only the comment author can edit or redact their comment.
Action::RevisionCommentEdit {
revision, comment, ..
}
| Action::RevisionCommentRedact {
revision, comment, ..
} => {
if let Some(revision) = lookup::revision(self, revision)? {
if let Some(comment) = revision.discussion.comment(comment) {
return Ok(Authorization::from(actor == &comment.author()));
}
}
// Redacted.
Authorization::Unknown
}
// Anyone can react to a revision.
Action::RevisionCommentReact { .. } => Authorization::Allow,
};
Ok(outcome)
}
}
impl Patch {
/// Apply an action after checking if it's authorized.
fn op_action<R: ReadRepository>(
&mut self,
action: Action,
id: EntryId,
author: ActorId,
timestamp: Timestamp,
concurrent: &[&cob::Entry],
doc: &DocAt,
repo: &R,
) -> Result<(), Error> {
match self.authorization(&action, &author, doc)? {
Authorization::Allow => {
self.action(action, id, author, timestamp, concurrent, doc, repo)
}
Authorization::Deny => Err(Error::NotAuthorized(author, Box::new(action))),
Authorization::Unknown => {
// In this case, since there is not enough information to determine
// whether the action is authorized or not, we simply ignore it.
// It's likely that the target object was redacted, and we can't
// verify whether the action would have been allowed or not.
Ok(())
}
}
}
/// Apply a single action to the patch.
fn action<R: ReadRepository>(
&mut self,
action: Action,
entry: EntryId,
author: ActorId,
timestamp: Timestamp,
_concurrent: &[&cob::Entry],
identity: &Doc,
repo: &R,
) -> Result<(), Error> {
match action {
Action::Edit { title, target } => {
self.title = title;
self.target = target;
}
Action::Lifecycle { state } => {
let valid = self.state == State::Draft
|| self.state == State::Archived
|| self.state == State::Open { conflicts: vec![] };
if valid {
match state {
Lifecycle::Open => {
self.state = State::Open { conflicts: vec![] };
}
Lifecycle::Draft => {
self.state = State::Draft;
}
Lifecycle::Archived => {
self.state = State::Archived;
}
}
}
}
Action::Label { labels } => {
self.labels = BTreeSet::from_iter(labels);
}
Action::Assign { assignees } => {
self.assignees = BTreeSet::from_iter(assignees.into_iter().map(ActorId::from));
}
Action::RevisionEdit {
revision,
description,
embeds,
} => {
if let Some(redactable) = self.revisions.get_mut(&revision) {
// If the revision was redacted concurrently, there's nothing to do.
if let Some(revision) = redactable {
revision.description.push(Edit::new(
author,
description,
timestamp,
embeds,
));
}
} else {
return Err(Error::Missing(revision.into_inner()));
}
}
Action::Revision {
description,
base,
oid,
resolves,
} => {
debug_assert!(!self.revisions.contains_key(&entry));
let id = RevisionId(entry);
self.revisions.insert(
id,
Some(Revision::new(
id,
author.into(),
description,
base,
oid,
timestamp,
resolves,
)),
);
}
Action::RevisionReact {
revision,
reaction,
active,
location,
} => {
if let Some(revision) = lookup::revision_mut(self, &revision)? {
let key = (author, reaction);
let reactions = revision.reactions.entry(location).or_default();
if active {
reactions.insert(key);
} else {
reactions.remove(&key);
}
}
}
Action::RevisionRedact { revision } => {
// Not allowed to delete the root revision.
let (root, _) = self.root();
if revision == root {
return Err(Error::NotAllowed(entry));
}
// Redactions must have observed a revision to be valid.
if let Some(r) = self.revisions.get_mut(&revision) {
// If the revision has already been merged, ignore the redaction. We
// don't want to redact merged revisions.
if self.merges.values().any(|m| m.revision == revision) {
return Ok(());
}
*r = None;
} else {
return Err(Error::Missing(revision.into_inner()));
}
}
Action::Review {
revision,
summary,
verdict,
labels,
} => {
let Some(rev) = self.revisions.get_mut(&revision) else {
// If the revision was redacted concurrently, there's nothing to do.
return Ok(());
};
if let Some(rev) = rev {
// Insert a review if there isn't already one. Otherwise we just ignore
// this operation
if let btree_map::Entry::Vacant(e) = rev.reviews.entry(author) {
let id = ReviewId(entry);
e.insert(Review::new(
id,
Author::new(author),
verdict,
summary.unwrap_or_default(),
labels,
vec![],
timestamp,
));
// Update reviews index.
self.reviews.insert(id, Some((revision, author)));
} else {
log::error!(
target: "patch",
"Review by {author} for {revision} already exists, ignoring action.."
);
}
}
}
Action::ReviewEdit(edit) => edit.run(author, timestamp, self)?,
Action::ReviewCommentReact {
review,
comment,
reaction,
active,
} => {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::react(
&mut review.comments,
entry,
author,
comment,
reaction,
active,
)?;
}
}
Action::ReviewCommentRedact { review, comment } => {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::redact(&mut review.comments, entry, comment)?;
}
}
Action::ReviewCommentEdit {
review,
comment,
body,
embeds,
} => {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::edit(
&mut review.comments,
entry,
author,
comment,
timestamp,
body,
embeds,
)?;
}
}
Action::ReviewCommentResolve { review, comment } => {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::resolve(&mut review.comments, entry, comment)?;
}
}
Action::ReviewCommentUnresolve { review, comment } => {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::unresolve(&mut review.comments, entry, comment)?;
}
}
Action::ReviewComment {
review,
body,
location,
reply_to,
embeds,
} => {
if let Some(review) = lookup::review_mut(self, &review)? {
thread::comment(
&mut review.comments,
entry,
author,
timestamp,
body,
reply_to,
location,
embeds,
)?;
}
}
Action::ReviewRedact { review } => {
// 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(());
};
// Remove review for this author.
if let Some(r) = revision.reviews.remove(reviewer) {
debug_assert_eq!(r.id, review);
} else {
log::error!(
target: "patch", "Review {review} not found in revision {}", revision.id
);
}
// Set the review locator in the review index to redacted.
*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 } => {
// If the revision was redacted before the merge, ignore the merge.
if lookup::revision_mut(self, &revision)?.is_none() {
return Ok(());
};
match self.target() {
MergeTarget::Delegates => {
let proj = identity.project()?;
let branch = git::refs::branch(proj.default_branch());
// Nb. We don't return an error in case the merge commit is not an
// ancestor of the default branch. The default branch can change
// *after* the merge action is created, which is out of the control
// of the merge author. We simply skip it, which allows archiving in
// case of a rebase off the master branch, or a redaction of the
// merge.
let Ok(head) = repo.reference_oid(&author, &branch) else {
return Ok(());
};
if commit != head && !repo.is_ancestor_of(commit, head)? {
return Ok(());
}
}
}
self.merges.insert(
author,
Merge {
revision,
commit,
timestamp,
},
);
let mut merges = self.merges.iter().fold(
HashMap::<(RevisionId, git::Oid), usize>::new(),
|mut acc, (_, merge)| {
*acc.entry((merge.revision, merge.commit)).or_default() += 1;
acc
},
);
// Discard revisions that weren't merged by a threshold of delegates.
merges.retain(|_, count| *count >= identity.threshold());
match merges.into_keys().collect::<Vec<_>>().as_slice() {
[] => {
// None of the revisions met the quorum.
}
[(revision, commit)] => {
// Patch is merged.
self.state = State::Merged {
revision: *revision,
commit: *commit,
};
}
revisions => {
// More than one revision met the quorum.
self.state = State::Open {
conflicts: revisions.to_vec(),
};
}
}
}
Action::RevisionComment {
revision,
body,
reply_to,
embeds,
location,
} => {
if let Some(revision) = lookup::revision_mut(self, &revision)? {
thread::comment(
&mut revision.discussion,
entry,
author,
timestamp,
body,
reply_to,
location,
embeds,
)?;
}
}
Action::RevisionCommentEdit {
revision,
comment,
body,
embeds,
} => {
if let Some(revision) = lookup::revision_mut(self, &revision)? {
thread::edit(
&mut revision.discussion,
entry,
author,
comment,
timestamp,
body,
embeds,
)?;
}
}
Action::RevisionCommentRedact { revision, comment } => {
if let Some(revision) = lookup::revision_mut(self, &revision)? {
thread::redact(&mut revision.discussion, entry, comment)?;
}
}
Action::RevisionCommentReact {
revision,
comment,
reaction,
active,
} => {
if let Some(revision) = lookup::revision_mut(self, &revision)? {
thread::react(
&mut revision.discussion,
entry,
author,
comment,
reaction,
active,
)?;
}
}
}
Ok(())
}
}
impl cob::store::CobWithType for Patch {
fn type_name() -> &'static TypeName {
&TYPENAME
}
}
impl store::Cob for Patch {
type Action = Action;
type Error = Error;
fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
let mut actions = op.actions.into_iter();
let Some(Action::Revision {
description,
base,
oid,
resolves,
}) = actions.next()
else {
return Err(Error::Init("the first action must be of type `revision`"));
};
let Some(Action::Edit { title, target }) = actions.next() else {
return Err(Error::Init("the second action must be of type `edit`"));
};
let revision = Revision::new(
RevisionId(op.id),
op.author.into(),
description,
base,
oid,
op.timestamp,
resolves,
);
let mut patch = Patch::new(title, target, (RevisionId(op.id), revision));
for action in actions {
match patch.authorization(&action, &op.author, &doc)? {
Authorization::Allow => {
patch.action(action, op.id, op.author, op.timestamp, &[], &doc, repo)?;
}
Authorization::Deny => {
return Err(Error::NotAuthorized(op.author, Box::new(action)));
}
Authorization::Unknown => {
// Note that this shouldn't really happen since there's no concurrency in the
// root operation.
continue;
}
}
}
Ok(patch)
}
fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a cob::Entry>>(
&mut self,
op: Op,
concurrent: I,
repo: &R,
) -> Result<(), Error> {
debug_assert!(!self.timeline.contains(&op.id));
self.timeline.push(op.id);
let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
let concurrent = concurrent.into_iter().collect::<Vec<_>>();
for action in op.actions {
log::trace!(target: "patch", "Applying {} {action:?}", op.id);
if let Err(e) = self.op_action(
action,
op.id,
op.author,
op.timestamp,
&concurrent,
&doc,
repo,
) {
log::error!(target: "patch", "Error applying {}: {e}", op.id);
return Err(e);
}
}
Ok(())
}
}
impl<R: ReadRepository> cob::Evaluate<R> for Patch {
type Error = Error;
fn init(entry: &cob::Entry, repo: &R) -> Result<Self, Self::Error> {
let op = Op::try_from(entry)?;
let object = Patch::from_root(op, repo)?;
Ok(object)
}
fn apply<'a, I: Iterator<Item = (&'a EntryId, &'a cob::Entry)>>(
&mut self,
entry: &cob::Entry,
concurrent: I,
repo: &R,
) -> Result<(), Self::Error> {
let op = Op::try_from(entry)?;
self.op(op, concurrent.map(|(_, e)| e), repo)
}
}
mod lookup {
use super::*;
pub fn revision<'a>(
patch: &'a Patch,
revision: &RevisionId,
) -> Result<Option<&'a Revision>, Error> {
match patch.revisions.get(revision) {
Some(Some(revision)) => Ok(Some(revision)),
// Redacted.
Some(None) => Ok(None),
// Missing. Causal error.
None => Err(Error::Missing(revision.into_inner())),
}
}
pub fn revision_mut<'a>(
patch: &'a mut Patch,
revision: &RevisionId,
) -> Result<Option<&'a mut Revision>, Error> {
match patch.revisions.get_mut(revision) {
Some(Some(revision)) => Ok(Some(revision)),
// Redacted.
Some(None) => Ok(None),
// Missing. Causal error.
None => Err(Error::Missing(revision.into_inner())),
}
}
pub fn review<'a>(
patch: &'a Patch,
review: &ReviewId,
) -> Result<Option<(&'a Revision, &'a Review)>, Error> {
match patch.reviews.get(review) {
Some(Some((revision, author))) => {
match patch.revisions.get(revision) {
Some(Some(rev)) => {
let r = rev
.reviews
.get(author)
.ok_or_else(|| Error::Missing(review.into_inner()))?;
debug_assert_eq!(&r.id, review);
Ok(Some((rev, r)))
}
Some(None) => {
// If the revision was redacted concurrently, there's nothing to do.
// Likewise, if the review was redacted concurrently, there's nothing to do.
Ok(None)
}
None => Err(Error::Missing(revision.into_inner())),
}
}
Some(None) => {
// Redacted.
Ok(None)
}
None => Err(Error::Missing(review.into_inner())),
}
}
pub fn review_mut<'a>(
patch: &'a mut Patch,
review: &ReviewId,
) -> Result<Option<&'a mut Review>, Error> {
match patch.reviews.get(review) {
Some(Some((revision, author))) => {
match patch.revisions.get_mut(revision) {
Some(Some(rev)) => {
let r = rev
.reviews
.get_mut(author)
.ok_or_else(|| Error::Missing(review.into_inner()))?;
debug_assert_eq!(&r.id, review);
Ok(Some(r))
}
Some(None) => {
// If the revision was redacted concurrently, there's nothing to do.
// Likewise, if the review was redacted concurrently, there's nothing to do.
Ok(None)
}
None => Err(Error::Missing(revision.into_inner())),
}
}
Some(None) => {
// Redacted.
Ok(None)
}
None => Err(Error::Missing(review.into_inner())),
}
}
}
/// A patch revision.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Revision {
/// Revision identifier.
pub(super) id: RevisionId,
/// Author of the revision.
pub(super) author: Author,
/// Revision description.
pub(super) description: NonEmpty<Edit>,
/// Base branch commit, used as a merge base.
pub(super) base: git::Oid,
/// Reference to the Git object containing the code (revision head).
pub(super) oid: git::Oid,
/// Discussion around this revision.
pub(super) discussion: Thread<Comment<CodeLocation>>,
/// Reviews of this revision's changes (all review edits are kept).
pub(super) reviews: BTreeMap<ActorId, Review>,
/// When this revision was created.
pub(super) timestamp: Timestamp,
/// Review comments resolved by this revision.
pub(super) resolves: BTreeSet<(EntryId, CommentId)>,
/// Reactions on code locations and revision itself
#[serde(
serialize_with = "ser::serialize_reactions",
deserialize_with = "ser::deserialize_reactions"
)]
pub(super) reactions: BTreeMap<Option<CodeLocation>, Reactions>,
}
impl Revision {
pub fn new(
id: RevisionId,
author: Author,
description: String,
base: git::Oid,
oid: git::Oid,
timestamp: Timestamp,
resolves: BTreeSet<(EntryId, CommentId)>,
) -> Self {
let description = Edit::new(*author.public_key(), description, timestamp, Vec::default());
Self {
id,
author,
description: NonEmpty::new(description),
base,
oid,
discussion: Thread::default(),
reviews: BTreeMap::default(),
timestamp,
resolves,
reactions: Default::default(),
}
}
pub fn id(&self) -> RevisionId {
self.id
}
pub fn description(&self) -> &str {
self.description.last().body.as_str()
}
pub fn edits(&self) -> impl Iterator<Item = &Edit> {
self.description.iter()
}
pub fn embeds(&self) -> &[Embed<Uri>] {
&self.description.last().embeds
}
pub fn reactions(&self) -> &BTreeMap<Option<CodeLocation>, BTreeSet<(PublicKey, Reaction)>> {
&self.reactions
}
/// Author of the revision.
pub fn author(&self) -> &Author {
&self.author
}
/// Base branch commit, used as a merge base.
pub fn base(&self) -> &git::Oid {
&self.base
}
/// Reference to the Git object containing the code (revision head).
pub fn head(&self) -> git::Oid {
self.oid
}
/// Get the commit range of this revision.
pub fn range(&self) -> (git::Oid, git::Oid) {
(self.base, self.oid)
}
/// When this revision was created.
pub fn timestamp(&self) -> Timestamp {
self.timestamp
}
/// Discussion around this revision.
pub fn discussion(&self) -> &Thread<Comment<CodeLocation>> {
&self.discussion
}
/// Review comments resolved by this revision.
pub fn resolves(&self) -> &BTreeSet<(EntryId, CommentId)> {
&self.resolves
}
/// Iterate over all top-level replies.
pub fn replies(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment<CodeLocation>)> {
self.discussion.comments()
}
/// Reviews of this revision's changes (one per actor).
pub fn reviews(&self) -> impl DoubleEndedIterator<Item = (&PublicKey, &Review)> {
self.reviews.iter()
}
/// Get a review by author.
pub fn review_by(&self, author: &ActorId) -> Option<&Review> {
self.reviews.get(author)
}
}
/// Patch state.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "status")]
pub enum State {
Draft,
Open {
/// Revisions that were merged and are conflicting.
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
conflicts: Vec<(RevisionId, git::Oid)>,
},
Archived,
Merged {
/// The revision that was merged.
revision: RevisionId,
/// The commit in the target branch that contains the changes.
commit: git::Oid,
},
}
impl Default for State {
fn default() -> Self {
Self::Open { conflicts: vec![] }
}
}
impl fmt::Display for State {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Archived => write!(f, "archived"),
Self::Draft => write!(f, "draft"),
Self::Open { .. } => write!(f, "open"),
Self::Merged { .. } => write!(f, "merged"),
}
}
}
impl From<&State> for Status {
fn from(value: &State) -> Self {
match value {
State::Draft => Self::Draft,
State::Open { .. } => Self::Open,
State::Archived => Self::Archived,
State::Merged { .. } => Self::Merged,
}
}
}
/// A simplified enumeration of a [`State`] that can be used for
/// filtering purposes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Status {
Draft,
#[default]
Open,
Archived,
Merged,
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Archived => write!(f, "archived"),
Self::Draft => write!(f, "draft"),
Self::Open => write!(f, "open"),
Self::Merged => write!(f, "merged"),
}
}
}
/// A lifecycle operation, resulting in a new state.
#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "status")]
pub enum Lifecycle {
#[default]
Open,
Draft,
Archived,
}
/// A merged patch revision.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "camelCase")]
pub struct Merge {
/// Revision that was merged.
pub revision: RevisionId,
/// Base branch commit that contains the revision.
pub commit: git::Oid,
/// When this merge was performed.
pub timestamp: Timestamp,
}
/// A patch review verdict.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Verdict {
/// Accept patch.
Accept,
/// Reject patch.
Reject,
}
impl fmt::Display for Verdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Accept => write!(f, "accept"),
Self::Reject => write!(f, "reject"),
}
}
}
/// A patch review on a revision.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(from = "encoding::review::Review")]
pub struct Review {
/// Review identifier.
pub(super) id: ReviewId,
/// Review author.
pub(super) author: Author,
/// Review verdict.
///
/// The verdict cannot be changed, since revisions are immutable.
pub(super) verdict: Option<Verdict>,
/// Review summary.
///
/// Can be empty, given there is a [`Verdict`].
///
/// 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.
pub(super) comments: Thread<Comment<CodeLocation>>,
/// 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.
pub(super) labels: Vec<Label>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
/// Reactions to the review.
pub(super) reactions: Reactions,
/// Review timestamp.
pub(super) timestamp: Timestamp,
}
impl Review {
pub fn new(
id: ReviewId,
author: Author,
verdict: Option<Verdict>,
summary: String,
labels: Vec<Label>,
embeds: Vec<Embed<Uri>>,
timestamp: Timestamp,
) -> Self {
let summary = NonEmpty::new(Edit::new(*author.public_key(), summary, timestamp, embeds));
Self {
id,
author,
verdict,
summary,
comments: Thread::default(),
reactions: BTreeSet::new(),
labels,
timestamp,
}
}
/// Review identifier.
pub fn id(&self) -> ReviewId {
self.id
}
/// Review author.
pub fn author(&self) -> &Author {
&self.author
}
/// Review verdict.
pub fn verdict(&self) -> Option<Verdict> {
self.verdict
}
/// Review inline code comments.
pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&EntryId, &Comment<CodeLocation>)> {
self.comments.comments()
}
/// Review labels.
pub fn labels(&self) -> impl Iterator<Item = &Label> {
self.labels.iter()
}
/// Review general comment.
pub fn summary(&self) -> &str {
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.
pub fn timestamp(&self) -> Timestamp {
self.timestamp
}
}
impl<R: ReadRepository> store::Transaction<Patch, R> {
pub fn edit(&mut self, title: cob::Title, target: MergeTarget) -> Result<(), store::Error> {
self.push(Action::Edit { title, target })
}
pub fn edit_revision(
&mut self,
revision: RevisionId,
description: impl ToString,
embeds: Vec<Embed<Uri>>,
) -> Result<(), store::Error> {
self.embed(embeds.clone())?;
self.push(Action::RevisionEdit {
revision,
description: description.to_string(),
embeds,
})
}
/// Redact the revision.
pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> {
self.push(Action::RevisionRedact { revision })
}
/// Start a patch revision discussion.
pub fn thread<S: ToString>(
&mut self,
revision: RevisionId,
body: S,
) -> Result<(), store::Error> {
self.push(Action::RevisionComment {
revision,
body: body.to_string(),
reply_to: None,
location: None,
embeds: vec![],
})
}
/// React on a patch revision.
pub fn react(
&mut self,
revision: RevisionId,
reaction: Reaction,
location: Option<CodeLocation>,
active: bool,
) -> Result<(), store::Error> {
self.push(Action::RevisionReact {
revision,
reaction,
location,
active,
})
}
/// Comment on a patch revision.
pub fn comment<S: ToString>(
&mut self,
revision: RevisionId,
body: S,
reply_to: Option<CommentId>,
location: Option<CodeLocation>,
embeds: Vec<Embed<Uri>>,
) -> Result<(), store::Error> {
self.embed(embeds.clone())?;
self.push(Action::RevisionComment {
revision,
body: body.to_string(),
reply_to,
location,
embeds,
})
}
/// Edit a comment on a patch revision.
pub fn comment_edit<S: ToString>(
&mut self,
revision: RevisionId,
comment: CommentId,
body: S,
embeds: Vec<Embed<Uri>>,
) -> Result<(), store::Error> {
self.embed(embeds.clone())?;
self.push(Action::RevisionCommentEdit {
revision,
comment,
body: body.to_string(),
embeds,
})
}
/// React a comment on a patch revision.
pub fn comment_react(
&mut self,
revision: RevisionId,
comment: CommentId,
reaction: Reaction,
active: bool,
) -> Result<(), store::Error> {
self.push(Action::RevisionCommentReact {
revision,
comment,
reaction,
active,
})
}
/// Redact a comment on a patch revision.
pub fn comment_redact(
&mut self,
revision: RevisionId,
comment: CommentId,
) -> Result<(), store::Error> {
self.push(Action::RevisionCommentRedact { revision, comment })
}
/// Comment on a review.
pub fn review_comment<S: ToString>(
&mut self,
review: ReviewId,
body: S,
location: Option<CodeLocation>,
reply_to: Option<CommentId>,
embeds: Vec<Embed<Uri>>,
) -> Result<(), store::Error> {
self.embed(embeds.clone())?;
self.push(Action::ReviewComment {
review,
body: body.to_string(),
location,
reply_to,
embeds,
})
}
/// Resolve a review comment.
pub fn review_comment_resolve(
&mut self,
review: ReviewId,
comment: CommentId,
) -> Result<(), store::Error> {
self.push(Action::ReviewCommentResolve { review, comment })
}
/// Unresolve a review comment.
pub fn review_comment_unresolve(
&mut self,
review: ReviewId,
comment: CommentId,
) -> Result<(), store::Error> {
self.push(Action::ReviewCommentUnresolve { review, comment })
}
/// Edit review comment.
pub fn edit_review_comment<S: ToString>(
&mut self,
review: ReviewId,
comment: EntryId,
body: S,
embeds: Vec<Embed<Uri>>,
) -> Result<(), store::Error> {
self.embed(embeds.clone())?;
self.push(Action::ReviewCommentEdit {
review,
comment,
body: body.to_string(),
embeds,
})
}
/// React to a review comment.
pub fn react_review_comment(
&mut self,
review: ReviewId,
comment: EntryId,
reaction: Reaction,
active: bool,
) -> Result<(), store::Error> {
self.push(Action::ReviewCommentReact {
review,
comment,
reaction,
active,
})
}
/// Redact a review comment.
pub fn redact_review_comment(
&mut self,
review: ReviewId,
comment: EntryId,
) -> Result<(), store::Error> {
self.push(Action::ReviewCommentRedact { review, comment })
}
/// Review a patch revision.
/// Does nothing if a review for that revision already exists by the author.
pub fn review(
&mut self,
revision: RevisionId,
verdict: Option<Verdict>,
summary: Option<String>,
labels: Vec<Label>,
) -> Result<(), store::Error> {
self.push(Action::Review {
revision,
summary,
verdict,
labels,
})
}
/// Edit a review.
pub fn review_edit(
&mut self,
review: ReviewId,
verdict: Option<Verdict>,
summary: String,
labels: Vec<Label>,
embeds: impl IntoIterator<Item = Embed<Uri>>,
) -> Result<(), store::Error> {
self.push(Action::ReviewEdit(actions::ReviewEdit::new(
review,
summary,
verdict,
labels,
embeds.into_iter().collect(),
)))
}
/// React to a review.
pub fn review_react(
&mut self,
review: ReviewId,
reaction: Reaction,
active: bool,
) -> Result<(), store::Error> {
self.push(Action::ReviewReact {
review,
reaction,
active,
})
}
/// Redact a patch review.
pub fn redact_review(&mut self, review: ReviewId) -> Result<(), store::Error> {
self.push(Action::ReviewRedact { review })
}
/// Merge a patch revision.
pub fn merge(&mut self, revision: RevisionId, commit: git::Oid) -> Result<(), store::Error> {
self.push(Action::Merge { revision, commit })
}
/// Update a patch with a new revision.
pub fn revision(
&mut self,
description: impl ToString,
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
) -> Result<(), store::Error> {
self.push(Action::Revision {
description: description.to_string(),
base: base.into(),
oid: oid.into(),
resolves: BTreeSet::new(),
})
}
/// Lifecycle a patch.
pub fn lifecycle(&mut self, state: Lifecycle) -> Result<(), store::Error> {
self.push(Action::Lifecycle { state })
}
/// Assign a patch.
pub fn assign(&mut self, assignees: BTreeSet<Did>) -> Result<(), store::Error> {
self.push(Action::Assign { assignees })
}
/// Label a patch.
pub fn label(&mut self, labels: impl IntoIterator<Item = Label>) -> Result<(), store::Error> {
self.push(Action::Label {
labels: labels.into_iter().collect(),
})
}
}
pub struct PatchMut<'a, 'b, 'g, Repo, Signer, Cache> {
pub id: ObjectId,
patch: Patch,
store: &'g mut Patches<'a, Repo, WriteAs<'b, Signer>>,
cache: &'g mut Cache,
}
impl<'a, 'b, 'g, Repo, Signer, Update> PatchMut<'a, 'b, 'g, Repo, Signer, Update>
where
Repo: ReadRepository + SignRepository + cob::Store<Namespace = NodeId>,
Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
Signer: crypto::signature::Signer<crypto::Signature>,
Signer: crypto::signature::Signer<crypto::ssh::ExtendedSignature>,
Signer: crypto::signature::Verifier<crypto::Signature>,
Update: cob::cache::Update<Patch>,
{
pub fn new(
id: ObjectId,
patch: Patch,
cache: &'g mut Cache<'a, Repo, WriteAs<'b, Signer>, Update>,
) -> Self {
Self {
id,
patch,
store: &mut cache.store,
cache: &mut cache.cache,
}
}
pub fn id(&self) -> &ObjectId {
&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<F>(&mut self, message: &str, operations: F) -> Result<EntryId, Error>
where
F: FnOnce(&mut Transaction<Patch, Repo>) -> Result<(), store::Error>,
{
let mut tx = Transaction::default();
operations(&mut tx)?;
let (patch, commit) = tx.commit(message, self.id, &mut self.store.raw)?;
self.cache
.update(&self.store.as_ref().id(), &self.id, &patch)
.map_err(|e| Error::CacheUpdate {
id: self.id,
err: e.into(),
})?;
self.patch = patch;
Ok(commit)
}
/// Edit patch metadata.
pub fn edit(&mut self, title: cob::Title, target: MergeTarget) -> Result<EntryId, Error> {
self.transaction("Edit", |tx| tx.edit(title, target))
}
/// Edit revision metadata.
pub fn edit_revision(
&mut self,
revision: RevisionId,
description: impl ToString,
embeds: impl IntoIterator<Item = Embed<Uri>>,
) -> Result<EntryId, Error> {
self.transaction("Edit revision", |tx| {
tx.edit_revision(revision, description, embeds.into_iter().collect())
})
}
/// Redact a revision.
pub fn redact(&mut self, revision: RevisionId) -> Result<EntryId, Error> {
self.transaction("Redact revision", |tx| tx.redact(revision))
}
/// Create a thread on a patch revision.
pub fn thread(
&mut self,
revision: RevisionId,
body: impl ToString,
) -> Result<CommentId, Error> {
self.transaction("Create thread", |tx| tx.thread(revision, body))
}
/// Comment on a patch revision.
pub fn comment(
&mut self,
revision: RevisionId,
body: impl ToString,
reply_to: Option<CommentId>,
location: Option<CodeLocation>,
embeds: impl IntoIterator<Item = Embed<Uri>>,
) -> Result<EntryId, Error> {
self.transaction("Comment", |tx| {
tx.comment(
revision,
body,
reply_to,
location,
embeds.into_iter().collect(),
)
})
}
/// React on a patch revision.
pub fn react(
&mut self,
revision: RevisionId,
reaction: Reaction,
location: Option<CodeLocation>,
active: bool,
) -> Result<EntryId, Error> {
self.transaction("React", |tx| tx.react(revision, reaction, location, active))
}
/// Edit a comment on a patch revision.
pub fn comment_edit(
&mut self,
revision: RevisionId,
comment: CommentId,
body: impl ToString,
embeds: impl IntoIterator<Item = Embed<Uri>>,
) -> Result<EntryId, Error> {
self.transaction("Edit comment", |tx| {
tx.comment_edit(revision, comment, body, embeds.into_iter().collect())
})
}
/// React to a comment on a patch revision.
pub fn comment_react(
&mut self,
revision: RevisionId,
comment: CommentId,
reaction: Reaction,
active: bool,
) -> Result<EntryId, Error> {
self.transaction("React comment", |tx| {
tx.comment_react(revision, comment, reaction, active)
})
}
/// Redact a comment on a patch revision.
pub fn comment_redact(
&mut self,
revision: RevisionId,
comment: CommentId,
) -> Result<EntryId, Error> {
self.transaction("Redact comment", |tx| tx.comment_redact(revision, comment))
}
/// Comment on a line of code as part of a review.
pub fn review_comment(
&mut self,
review: ReviewId,
body: impl ToString,
location: Option<CodeLocation>,
reply_to: Option<CommentId>,
embeds: impl IntoIterator<Item = Embed<Uri>>,
) -> Result<EntryId, Error> {
self.transaction("Review comment", |tx| {
tx.review_comment(
review,
body,
location,
reply_to,
embeds.into_iter().collect(),
)
})
}
/// Edit review comment.
pub fn edit_review_comment(
&mut self,
review: ReviewId,
comment: EntryId,
body: impl ToString,
embeds: impl IntoIterator<Item = Embed<Uri>>,
) -> Result<EntryId, Error> {
self.transaction("Edit review comment", |tx| {
tx.edit_review_comment(review, comment, body, embeds.into_iter().collect())
})
}
/// React to a review comment.
pub fn react_review_comment(
&mut self,
review: ReviewId,
comment: EntryId,
reaction: Reaction,
active: bool,
) -> Result<EntryId, Error> {
self.transaction("React to review comment", |tx| {
tx.react_review_comment(review, comment, reaction, active)
})
}
/// React to a review comment.
pub fn redact_review_comment(
&mut self,
review: ReviewId,
comment: EntryId,
) -> Result<EntryId, Error> {
self.transaction("Redact review comment", |tx| {
tx.redact_review_comment(review, comment)
})
}
/// Review a patch revision.
pub fn review(
&mut self,
revision: RevisionId,
verdict: Option<Verdict>,
summary: Option<String>,
labels: Vec<Label>,
) -> Result<ReviewId, Error> {
if verdict.is_none() && summary.is_none() {
return Err(Error::EmptyReview);
}
self.transaction("Review", |tx| tx.review(revision, verdict, summary, labels))
.map(ReviewId)
}
/// Edit a review.
pub fn review_edit(
&mut self,
review: ReviewId,
verdict: Option<Verdict>,
summary: String,
labels: Vec<Label>,
embeds: impl IntoIterator<Item = Embed<Uri>>,
) -> Result<EntryId, Error> {
self.transaction("Edit review", |tx| {
tx.review_edit(review, verdict, summary, labels, embeds)
})
}
/// React to a review.
pub fn review_react(
&mut self,
review: ReviewId,
reaction: Reaction,
active: bool,
) -> Result<EntryId, Error> {
self.transaction("React to review", |tx| {
tx.review_react(review, reaction, active)
})
}
/// Redact a patch review.
pub fn redact_review(&mut self, review: ReviewId) -> Result<EntryId, Error> {
self.transaction("Redact review", |tx| tx.redact_review(review))
}
/// Resolve a patch review comment.
pub fn resolve_review_comment(
&mut self,
review: ReviewId,
comment: CommentId,
) -> Result<EntryId, Error> {
self.transaction("Resolve review comment", |tx| {
tx.review_comment_resolve(review, comment)
})
}
/// Unresolve a patch review comment.
pub fn unresolve_review_comment(
&mut self,
review: ReviewId,
comment: CommentId,
) -> Result<EntryId, Error> {
self.transaction("Unresolve review comment", |tx| {
tx.review_comment_unresolve(review, comment)
})
}
/// Merge a patch revision.
pub fn merge(
&mut self,
revision: RevisionId,
commit: git::Oid,
) -> Result<Merged<'_, Repo>, Error> {
// TODO: Don't allow merging the same revision twice?
let entry = self.transaction("Merge revision", |tx| tx.merge(revision, commit))?;
Ok(Merged {
entry,
patch: self.id,
stored: self.store.as_ref(),
})
}
/// Update a patch with a new revision.
pub fn update(
&mut self,
description: impl ToString,
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
) -> Result<RevisionId, Error> {
self.transaction("Add revision", |tx| tx.revision(description, base, oid))
.map(RevisionId)
}
/// Lifecycle a patch.
pub fn lifecycle(&mut self, state: Lifecycle) -> Result<EntryId, Error> {
self.transaction("Lifecycle", |tx| tx.lifecycle(state))
}
/// Assign a patch.
pub fn assign(&mut self, assignees: BTreeSet<Did>) -> Result<EntryId, Error> {
self.transaction("Assign", |tx| tx.assign(assignees))
}
/// Archive a patch.
pub fn archive(&mut self) -> Result<bool, Error> {
self.lifecycle(Lifecycle::Archived)?;
Ok(true)
}
/// Mark an archived patch as ready to be reviewed again.
/// Returns `false` if the patch was not archived.
pub fn unarchive(&mut self) -> Result<bool, Error> {
if !self.is_archived() {
return Ok(false);
}
self.lifecycle(Lifecycle::Open)?;
Ok(true)
}
/// Mark a patch as ready to be reviewed.
/// Returns `false` if the patch was not a draft.
pub fn ready(&mut self) -> Result<bool, Error> {
if !self.is_draft() {
return Ok(false);
}
self.lifecycle(Lifecycle::Open)?;
Ok(true)
}
/// Mark an open patch as a draft.
/// Returns `false` if the patch was not open and free of merges.
pub fn unready(&mut self) -> Result<bool, Error> {
if !matches!(self.state(), State::Open { conflicts } if conflicts.is_empty()) {
return Ok(false);
}
self.lifecycle(Lifecycle::Draft)?;
Ok(true)
}
/// Label a patch.
pub fn label(&mut self, labels: impl IntoIterator<Item = Label>) -> Result<EntryId, Error> {
self.transaction("Label", |tx| tx.label(labels))
}
}
impl<Repo, Signer, Cache> Deref for PatchMut<'_, '_, '_, Repo, Signer, Cache> {
type Target = Patch;
fn deref(&self) -> &Self::Target {
&self.patch
}
}
/// Detailed information on patch states
#[derive(Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PatchCounts {
pub open: usize,
pub draft: usize,
pub archived: usize,
pub merged: usize,
}
impl PatchCounts {
/// Total count.
pub fn total(&self) -> usize {
self.open + self.draft + self.archived + self.merged
}
}
/// Result of looking up a `Patch`'s `Revision`.
///
/// See [`Patches::find_by_revision`].
#[derive(Debug, PartialEq, Eq)]
pub struct ByRevision {
pub id: PatchId,
pub patch: Patch,
pub revision_id: RevisionId,
pub revision: Revision,
}
pub struct Patches<'a, Repo, Access> {
raw: store::Store<'a, Patch, Repo, Access>,
}
impl<'a, Repo, Access> Deref for Patches<'a, Repo, Access> {
type Target = store::Store<'a, Patch, Repo, Access>;
fn deref(&self) -> &Self::Target {
&self.raw
}
}
impl<Repo, Access> HasRepoId for Patches<'_, Repo, Access>
where
Repo: HasRepoId,
{
fn rid(&self) -> RepoId {
self.raw.rid()
}
}
impl<'a, Repo, Access> Patches<'a, Repo, Access>
where
Repo: ReadRepository + cob::Store<Namespace = NodeId>,
Access: store::access::Access,
{
/// Open a patches store.
pub fn open(repository: &'a Repo, access: Access) -> Result<Self, RepositoryError> {
let identity = repository.identity_head()?;
let raw = store::Store::open(repository, access)?.identity(identity);
Ok(Self { raw })
}
}
impl<'a, Repo, Access> Patches<'a, Repo, Access>
where
Repo: ReadRepository + cob::Store<Namespace = NodeId>,
Access: store::access::Access,
{
/// Patches count by state.
pub fn counts(&self) -> Result<PatchCounts, store::Error> {
let all = self.all()?;
let state_groups =
all.filter_map(|s| s.ok())
.fold(PatchCounts::default(), |mut state, (_, p)| {
match p.state() {
State::Draft => state.draft += 1,
State::Open { .. } => state.open += 1,
State::Archived => state.archived += 1,
State::Merged { .. } => state.merged += 1,
}
state
});
Ok(state_groups)
}
/// Find the `Patch` containing the given `Revision`.
pub fn find_by_revision(&self, revision: &RevisionId) -> Result<Option<ByRevision>, Error> {
// Revision may be the patch's first, making it have the same ID.
let p_id = ObjectId::from(revision.into_inner());
if let Some(p) = self.get(&p_id)? {
return Ok(p.revision(revision).map(|r| ByRevision {
id: p_id,
patch: p.clone(),
revision_id: *revision,
revision: r.clone(),
}));
}
let result = self
.all()?
.filter_map(|result| result.ok())
.find_map(|(p_id, p)| {
p.revision(revision).map(|r| ByRevision {
id: p_id,
patch: p.clone(),
revision_id: *revision,
revision: r.clone(),
})
});
Ok(result)
}
/// Get a patch.
pub fn get(&self, id: &ObjectId) -> Result<Option<Patch>, store::Error> {
self.raw.get(id)
}
/// Get proposed patches.
pub fn proposed(&self) -> Result<impl Iterator<Item = (PatchId, Patch)> + '_, Error> {
let all = self.all()?;
Ok(all
.into_iter()
.filter_map(|result| result.ok())
.filter(|(_, p)| p.is_open()))
}
/// Get patches proposed by the given key.
pub fn proposed_by<'b>(
&'b self,
who: &'b Did,
) -> Result<impl Iterator<Item = (PatchId, Patch)> + 'b, Error> {
Ok(self
.proposed()?
.filter(move |(_, p)| p.author().id() == who))
}
}
impl<'a, 'b, Repo, Signer> Patches<'a, Repo, WriteAs<'b, Signer>>
where
Repo: ReadRepository + SignRepository + cob::Store<Namespace = NodeId>,
Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
Signer: crypto::signature::Signer<crypto::Signature>,
{
/// Open a new patch.
pub fn create<'g, Cache>(
&'g mut self,
title: cob::Title,
description: impl ToString,
target: MergeTarget,
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
labels: &[Label],
cache: &'g mut Cache,
) -> Result<PatchMut<'a, 'b, 'g, Repo, Signer, Cache>, Error>
where
Cache: cob::cache::Update<Patch>,
Signer: crypto::signature::Signer<crypto::Signature>,
Signer: crypto::signature::Signer<crypto::ssh::ExtendedSignature>,
Signer: crypto::signature::Verifier<crypto::Signature>,
{
self._create(
title,
description,
target,
base,
oid,
labels,
Lifecycle::default(),
cache,
)
}
/// Draft a patch. This patch will be created in a [`State::Draft`] state.
pub fn draft<'g, Cache>(
&'g mut self,
title: cob::Title,
description: impl ToString,
target: MergeTarget,
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
labels: &[Label],
cache: &'g mut Cache,
) -> Result<PatchMut<'a, 'b, 'g, Repo, Signer, Cache>, Error>
where
Cache: cob::cache::Update<Patch>,
Signer: crypto::signature::Signer<crypto::ssh::ExtendedSignature>,
Signer: crypto::signature::Verifier<crypto::Signature>,
{
self._create(
title,
description,
target,
base,
oid,
labels,
Lifecycle::Draft,
cache,
)
}
/// Get a patch mutably.
pub fn get_mut<'g, Cache>(
&'g mut self,
id: &ObjectId,
cache: &'g mut Cache,
) -> Result<PatchMut<'a, 'b, 'g, Repo, Signer, Cache>, store::Error> {
let patch = self
.raw
.get(id)?
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
Ok(PatchMut {
id: *id,
patch,
store: self,
cache,
})
}
/// Create a patch. This is an internal function used by `create` and `draft`.
fn _create<'g, Cache>(
&'g mut self,
title: cob::Title,
description: impl ToString,
target: MergeTarget,
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
labels: &[Label],
state: Lifecycle,
cache: &'g mut Cache,
) -> Result<PatchMut<'a, 'b, 'g, Repo, Signer, Cache>, Error>
where
Cache: cob::cache::Update<Patch>,
Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
Signer: crypto::signature::Signer<crypto::Signature>,
Signer: crypto::signature::Signer<crypto::ssh::ExtendedSignature>,
Signer: crypto::signature::Verifier<crypto::Signature>,
{
let (id, patch) = Transaction::initial("Create patch", &mut self.raw, |tx, _| {
tx.revision(description, base, oid)?;
tx.edit(title, target)?;
if !labels.is_empty() {
tx.label(labels.to_owned())?;
}
if state != Lifecycle::default() {
tx.lifecycle(state)?;
}
Ok(())
})?;
cache
.update(&self.raw.as_ref().id(), &id, &patch)
.map_err(|e| Error::CacheUpdate { id, err: e.into() })?;
Ok(PatchMut {
id,
patch,
store: self,
cache,
})
}
}
/// Models a comparison between two commit ranges,
/// commonly obtained from two revisions (likely of the same patch).
/// This can be used to generate a `git range-diff` command.
/// See <https://git-scm.com/docs/git-range-diff>.
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
pub struct RangeDiff {
old: (git::Oid, git::Oid),
new: (git::Oid, git::Oid),
}
impl RangeDiff {
const COMMAND: &str = "git";
const SUBCOMMAND: &str = "range-diff";
pub fn new(old: &Revision, new: &Revision) -> Self {
Self {
old: old.range(),
new: new.range(),
}
}
pub fn to_command(&self) -> String {
let range = if self.has_same_base() {
format!("{} {} {}", self.old.0, self.old.1, self.new.1)
} else {
format!(
"{}..{} {}..{}",
self.old.0, self.old.1, self.new.0, self.new.1,
)
};
Self::COMMAND.to_string() + " " + Self::SUBCOMMAND + " " + &range
}
fn has_same_base(&self) -> bool {
self.old.0 == self.new.0
}
}
impl From<RangeDiff> for std::process::Command {
fn from(range_diff: RangeDiff) -> Self {
let mut command = std::process::Command::new(RangeDiff::COMMAND);
command.arg(RangeDiff::SUBCOMMAND);
if range_diff.has_same_base() {
command.args([
range_diff.old.0.to_string(),
range_diff.old.1.to_string(),
range_diff.new.1.to_string(),
]);
} else {
command.args([
format!("{}..{}", range_diff.old.0, range_diff.old.1),
format!("{}..{}", range_diff.new.0, range_diff.new.1),
]);
}
command
}
}
/// Helpers for de/serialization of patch data types.
mod ser {
use std::collections::{BTreeMap, BTreeSet};
use serde::ser::SerializeSeq;
use crate::cob::{ActorId, CodeLocation, thread::Reactions};
/// Serialize a `Revision`'s reaction as an object containing the
/// `location`, `emoji`, and all `authors` that have performed the
/// same reaction.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Reaction {
location: Option<CodeLocation>,
emoji: super::Reaction,
authors: Vec<ActorId>,
}
impl Reaction {
fn as_revision_reactions(
reactions: Vec<Reaction>,
) -> BTreeMap<Option<CodeLocation>, Reactions> {
reactions.into_iter().fold(
BTreeMap::<Option<CodeLocation>, Reactions>::new(),
|mut reactions,
Reaction {
location,
emoji,
authors,
}| {
let mut inner = authors
.into_iter()
.map(|author| (author, emoji))
.collect::<BTreeSet<_>>();
let entry = reactions.entry(location).or_default();
entry.append(&mut inner);
reactions
},
)
}
}
/// Helper to serialize a `Revision`'s reactions, since
/// `CodeLocation` cannot be a key for a JSON object.
///
/// The set `reactions` are first turned into a set of
/// [`Reaction`]s and then serialized via a `Vec`.
pub fn serialize_reactions<S>(
reactions: &BTreeMap<Option<CodeLocation>, Reactions>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let reactions = reactions
.iter()
.flat_map(|(location, reaction)| {
let reactions = reaction.iter().fold(
BTreeMap::new(),
|mut acc: BTreeMap<&super::Reaction, Vec<_>>, (author, emoji)| {
acc.entry(emoji).or_default().push(*author);
acc
},
);
reactions
.into_iter()
.map(|(emoji, authors)| Reaction {
location: location.clone(),
emoji: *emoji,
authors,
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let mut s = serializer.serialize_seq(Some(reactions.len()))?;
for r in &reactions {
s.serialize_element(r)?;
}
s.end()
}
/// Helper to deserialize a `Revision`'s reactions, the inverse of
/// `serialize_reactions`.
///
/// The `Vec` of [`Reaction`]s are deserialized and converted to a
/// `BTreeMap<Option<CodeLocation>, Reactions>`.
pub fn deserialize_reactions<'de, D>(
deserializer: D,
) -> Result<BTreeMap<Option<CodeLocation>, Reactions>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ReactionsVisitor;
impl<'de> serde::de::Visitor<'de> for ReactionsVisitor {
type Value = Vec<Reaction>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a reaction of the form {'location', 'emoji', 'authors'}")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut reactions = Vec::new();
while let Some(reaction) = seq.next_element()? {
reactions.push(reaction);
}
Ok(reactions)
}
}
let reactions = deserializer.deserialize_seq(ReactionsVisitor)?;
Ok(Reaction::as_revision_reactions(reactions))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
use std::path::PathBuf;
use std::str::FromStr;
use std::vec;
use pretty_assertions::assert_eq;
use super::*;
use crate::cob::common::CodeRange;
use crate::cob::test::Actor;
use crate::crypto::test::signer::MockSigner;
use crate::identity;
use crate::patch::cache::Patches as _;
use crate::profile::env;
use crate::test;
use crate::test::arbitrary;
use crate::test::arbitrary::r#gen;
use crate::test::storage::MockRepository;
use cob::migrate;
#[test]
fn test_json_serialization() {
let edit = Action::Label {
labels: BTreeSet::new(),
};
assert_eq!(
serde_json::to_string(&edit).unwrap(),
String::from(r#"{"type":"label","labels":[]}"#)
);
}
#[test]
fn test_reactions_json_serialization() {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct TestReactions {
#[serde(
serialize_with = "super::ser::serialize_reactions",
deserialize_with = "super::ser::deserialize_reactions"
)]
inner: BTreeMap<Option<CodeLocation>, Reactions>,
}
let reactions = TestReactions {
inner: [(
None,
[
(
"z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"
.parse()
.unwrap(),
Reaction::new('🚀').unwrap(),
),
(
"z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
.parse()
.unwrap(),
Reaction::new('🙏').unwrap(),
),
]
.into_iter()
.collect(),
)]
.into_iter()
.collect(),
};
assert_eq!(
reactions,
serde_json::from_str(&serde_json::to_string(&reactions).unwrap()).unwrap()
);
}
#[test]
fn test_patch_create_and_get() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Cache::no_cache(&*alice.repo, &alice.signer).unwrap();
let author: Did = alice.signer.public_key().into();
let target = MergeTarget::Delegates;
let patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
target,
branch.base,
branch.oid,
&[],
)
.unwrap();
let patch_id = patch.id;
let patch = patches.get(&patch_id).unwrap().unwrap();
assert_eq!(patch.title(), "My first patch");
assert_eq!(patch.description(), "Blah blah blah.");
assert_eq!(patch.author().id(), &author);
assert_eq!(patch.state(), &State::Open { conflicts: vec![] });
assert_eq!(patch.target(), target);
assert_eq!(patch.version(), 0);
let (rev_id, revision) = patch.latest();
assert_eq!(revision.author.id(), &author);
assert_eq!(revision.description(), "Blah blah blah.");
assert_eq!(revision.discussion.len(), 0);
assert_eq!(revision.oid, branch.oid);
assert_eq!(revision.base, branch.base);
let ByRevision { id, .. } = patches.find_by_revision(&rev_id).unwrap().unwrap();
assert_eq!(id, patch_id);
}
#[test]
fn test_patch_discussion() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Cache::no_cache(&*alice.repo, &alice.signer).unwrap();
let patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
)
.unwrap();
let id = patch.id;
let mut patch = patches.get_mut(&id).unwrap();
let (revision_id, _) = patch.revisions().last().unwrap();
assert!(
patch
.comment(revision_id, "patch comment", None, None, [],)
.is_ok(),
"can comment on patch"
);
let (_, revision) = patch.revisions().last().unwrap();
let (_, comment) = revision.discussion.first().unwrap();
assert_eq!("patch comment", comment.body(), "comment body untouched");
}
#[test]
fn test_patch_merge() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Cache::no_cache(&*alice.repo, &alice.signer).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
)
.unwrap();
let id = patch.id;
let (rid, _) = patch.revisions().next().unwrap();
let _merge = patch.merge(rid, branch.base).unwrap();
let patch = patches.get(&id).unwrap().unwrap();
let merges = patch.merges.iter().collect::<Vec<_>>();
assert_eq!(merges.len(), 1);
let (merger, merge) = merges.first().unwrap();
assert_eq!(*merger, alice.signer.public_key());
assert_eq!(merge.commit, branch.base);
}
#[test]
fn test_patch_review() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Cache::no_cache(&*alice.repo, &alice.signer).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
)
.unwrap();
let (revision_id, _) = patch.latest();
let review_id = patch
.review(
revision_id,
Some(Verdict::Accept),
Some("LGTM".to_owned()),
vec![],
)
.unwrap();
let id = patch.id;
let mut patch = patches.get_mut(&id).unwrap();
let (_, revision) = patch.latest();
assert_eq!(revision.reviews.len(), 1);
let review = revision.review_by(alice.signer.public_key()).unwrap();
assert_eq!(review.verdict(), Some(Verdict::Accept));
assert_eq!(review.summary(), "LGTM");
patch.redact_review(review_id).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).unwrap();
// If the review never existed, it's an error.
patch
.redact_review(ReviewId(arbitrary::entry_id()))
.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 = Cache::no_cache(&*alice.repo, &alice.signer).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
)
.unwrap();
let update = checkout.branch_with([("README", b"Hello Radicle!")]);
let updated = patch
.update("I've made changes.", branch.base, update.oid)
.unwrap();
// It's fine to redact a review from a redacted revision.
let review = patch
.review(updated, Some(Verdict::Accept), None, vec![])
.unwrap();
patch.redact(updated).unwrap();
patch.redact_review(review).unwrap();
}
#[test]
fn test_revision_review_merge_redacted() {
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
let mut alice = Actor::<MockSigner>::default();
let rid = r#gen::<RepoId>(1);
let doc = RawDoc::new(
r#gen::<Project>(1),
vec![alice.did()],
1,
identity::Visibility::Public,
)
.verified()
.unwrap();
let repo = MockRepository::new(rid, doc);
let a1 = alice.op::<Patch>([
Action::Revision {
description: String::new(),
base,
oid,
resolves: Default::default(),
},
Action::Edit {
title: cob::Title::new("My patch").unwrap(),
target: MergeTarget::Delegates,
},
]);
let a2 = alice.op::<Patch>([Action::Revision {
description: String::from("Second revision"),
base,
oid,
resolves: Default::default(),
}]);
let a3 = alice.op::<Patch>([Action::RevisionRedact {
revision: RevisionId(a2.id()),
}]);
let a4 = alice.op::<Patch>([Action::Review {
revision: RevisionId(a2.id()),
summary: None,
verdict: Some(Verdict::Accept),
labels: vec![],
}]);
let a5 = alice.op::<Patch>([Action::Merge {
revision: RevisionId(a2.id()),
commit: oid,
}]);
let mut patch = Patch::from_ops([a1, a2], &repo).unwrap();
assert_eq!(patch.revisions().count(), 2);
patch.op(a3, [], &repo).unwrap();
assert_eq!(patch.revisions().count(), 1);
patch.op(a4, [], &repo).unwrap();
patch.op(a5, [], &repo).unwrap();
}
#[test]
fn test_revision_edit_redact() {
let base = arbitrary::oid();
let oid = arbitrary::oid();
let repo = r#gen::<MockRepository>(1);
let time = env::local_time();
let alice = MockSigner::default();
let bob = MockSigner::default();
let mut h0: cob::test::HistoryBuilder<Patch> = cob::test::history(
&[
Action::Revision {
description: String::from("Original"),
base,
oid,
resolves: Default::default(),
},
Action::Edit {
title: cob::Title::new("Some patch").unwrap(),
target: MergeTarget::Delegates,
},
],
time.into(),
&alice,
);
let r1 = h0.commit(
&Action::Revision {
description: String::from("New"),
base,
oid,
resolves: Default::default(),
},
&alice,
);
let patch: Patch = Patch::from_history(&h0, &repo).unwrap();
assert_eq!(patch.revisions().count(), 2);
let mut h1 = h0.clone();
h1.commit(
&Action::RevisionRedact {
revision: RevisionId(r1),
},
&alice,
);
let mut h2 = h0.clone();
h2.commit(
&Action::RevisionEdit {
revision: RevisionId(*h0.root().id()),
description: String::from("Edited"),
embeds: Vec::default(),
},
&bob,
);
h0.merge(h1);
h0.merge(h2);
let patch = Patch::from_history(&h0, &repo).unwrap();
assert_eq!(patch.revisions().count(), 1);
}
#[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::<MockSigner>::default();
let repo = r#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(),
},
Action::Edit {
title: cob::Title::new("My patch").unwrap(),
target: MergeTarget::Delegates,
},
]);
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]
fn test_patch_review_edit() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Cache::no_cache(&*alice.repo, &alice.signer).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
)
.unwrap();
let (rid, _) = patch.latest();
let review = patch
.review(rid, Some(Verdict::Accept), Some("LGTM".to_owned()), vec![])
.unwrap();
patch
.review_edit(
review,
Some(Verdict::Reject),
"Whoops!".to_owned(),
vec![],
vec![],
)
.unwrap(); // Overwrite the comment.
let (_, revision) = patch.latest();
let review = revision.review_by(alice.signer.public_key()).unwrap();
assert_eq!(review.verdict(), Some(Verdict::Reject));
assert_eq!(review.summary(), "Whoops!");
}
#[test]
fn test_patch_review_duplicate() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Cache::no_cache(&*alice.repo, &alice.signer).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
)
.unwrap();
let (rid, _) = patch.latest();
patch
.review(rid, Some(Verdict::Accept), None, vec![])
.unwrap();
patch
.review(rid, Some(Verdict::Reject), None, vec![])
.unwrap(); // This review is ignored, since there is already a review by this author.
let (_, revision) = patch.latest();
let review = revision.review_by(alice.signer.public_key()).unwrap();
assert_eq!(review.verdict(), Some(Verdict::Accept));
}
#[test]
fn test_patch_review_edit_comment() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Cache::no_cache(&*alice.repo, &alice.signer).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
)
.unwrap();
let (rid, _) = patch.latest();
let review = patch
.review(rid, Some(Verdict::Accept), None, vec![])
.unwrap();
patch
.review_comment(review, "First comment!", None, None, [])
.unwrap();
let _review = patch
.review_edit(
review,
Some(Verdict::Reject),
"".to_string(),
vec![],
vec![],
)
.unwrap();
patch
.review_comment(review, "Second comment!", None, None, [])
.unwrap();
let (_, revision) = patch.latest();
let review = revision.review_by(alice.signer.public_key()).unwrap();
assert_eq!(review.verdict(), Some(Verdict::Reject));
assert_eq!(review.comments().count(), 2);
assert_eq!(review.comments().next().unwrap().1.body(), "First comment!");
assert_eq!(
review.comments().nth(1).unwrap().1.body(),
"Second comment!"
);
}
#[test]
fn test_patch_review_comment() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Cache::no_cache(&*alice.repo, &alice.signer).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
)
.unwrap();
let (rid, _) = patch.latest();
let location = CodeLocation {
commit: branch.oid,
path: PathBuf::from_str("README").unwrap(),
old: None,
new: Some(CodeRange::Lines { range: 5..8 }),
};
let review = patch
.review(rid, Some(Verdict::Accept), None, vec![])
.unwrap();
patch
.review_comment(
review,
"I like these lines of code",
Some(location.clone()),
None,
[],
)
.unwrap();
let (_, revision) = patch.latest();
let review = revision.review_by(alice.signer.public_key()).unwrap();
let (_, comment) = review.comments().next().unwrap();
assert_eq!(comment.body(), "I like these lines of code");
assert_eq!(comment.location(), Some(&location));
}
#[test]
fn test_patch_review_remove_summary() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Cache::no_cache(&*alice.repo, &alice.signer).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
)
.unwrap();
let (rid, _) = patch.latest();
let review = patch
.review(rid, Some(Verdict::Accept), Some("Nah".to_owned()), vec![])
.unwrap();
patch
.review_edit(
review,
Some(Verdict::Accept),
"".to_string(),
vec![],
vec![],
)
.unwrap();
let id = patch.id;
let patch = patches.get_mut(&id).unwrap();
let (_, revision) = patch.latest();
let review = revision.review_by(alice.signer.public_key()).unwrap();
assert_eq!(review.verdict(), Some(Verdict::Accept));
assert_eq!(review.summary(), "");
}
#[test]
fn test_patch_update() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = {
let path = alice.tmp.path().join("cobs.db");
let mut db = cob::cache::Store::open(path).unwrap();
let store =
cob::patch::Patches::open(&*alice.repo, WriteAs::new(&alice.signer)).unwrap();
db.migrate(migrate::ignore).unwrap();
cob::patch::Cache::open(store, db)
};
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
)
.unwrap();
assert_eq!(patch.description(), "Blah blah blah.");
assert_eq!(patch.version(), 0);
let update = checkout.branch_with([("README", b"Hello Radicle!")]);
let _ = patch
.update("I've made changes.", branch.base, update.oid)
.unwrap();
let id = patch.id;
let patch = patches.get(&id).unwrap().unwrap();
assert_eq!(patch.version(), 1);
assert_eq!(patch.revisions.len(), 2);
assert_eq!(patch.revisions().count(), 2);
assert_eq!(
patch.revisions().next().unwrap().1.description(),
"Blah blah blah."
);
assert_eq!(
patch.revisions().nth(1).unwrap().1.description(),
"I've made changes."
);
let (_, revision) = patch.latest();
assert_eq!(patch.version(), 1);
assert_eq!(revision.oid, update.oid);
assert_eq!(revision.description(), "I've made changes.");
}
#[test]
fn test_patch_redact() {
let alice = test::setup::Node::default();
let repo = alice.project();
let branch = repo
.checkout()
.branch_with([("README.md", b"Hello, World!")]);
let mut patches = Cache::no_cache(&*repo, &alice.signer).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
)
.unwrap();
let patch_id = patch.id;
let update = repo
.checkout()
.branch_with([("README.md", b"Hello, Radicle!")]);
let revision_id = patch
.update("I've made changes.", branch.base, update.oid)
.unwrap();
assert_eq!(patch.revisions().count(), 2);
patch.redact(revision_id).unwrap();
assert_eq!(patch.latest().0, RevisionId(*patch_id));
assert_eq!(patch.revisions().count(), 1);
// The patch's root must always exist.
assert_eq!(patch.latest(), patch.root());
assert!(patch.redact(patch.latest().0).is_err());
}
#[test]
fn test_json() {
use serde_json::json;
assert_eq!(
serde_json::to_value(Action::Lifecycle {
state: Lifecycle::Draft
})
.unwrap(),
json!({
"type": "lifecycle",
"state": { "status": "draft" }
})
);
let revision = RevisionId(arbitrary::entry_id());
assert_eq!(
serde_json::to_value(Action::Review {
revision,
summary: None,
verdict: None,
labels: vec![],
})
.unwrap(),
json!({
"type": "review",
"revision": revision,
})
);
assert_eq!(
serde_json::to_value(CodeRange::Lines { range: 4..8 }).unwrap(),
json!({
"type": "lines",
"range": { "start": 4, "end": 8 },
})
);
}
}