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::{Cob, CobAction};
use crate::cob::thread;
use crate::cob::thread::Thread;
use crate::cob::thread::{Comment, CommentId, Edit, Reactions};
use crate::cob::{op, store, ActorId, Embed, EntryId, ObjectId, TypeName, Uri};
use crate::crypto::PublicKey;
use crate::git;
use crate::identity::doc::{DocAt, DocError};
use crate::identity::PayloadError;
use crate::node::device::Device;
use crate::prelude::*;
use crate::storage;
pub use cache::Cache;
pub static TYPENAME: LazyLock<TypeName> =
LazyLock::new(|| FromStr::from_str("xyz.radicle.patch").expect("type name is valid"));
pub type Op = cob::Op<Action>;
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())
}
}
#[derive(
Wrapper,
Debug,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
From,
Display,
)]
#[display(inner)]
#[wrap(Deref)]
pub struct RevisionId(EntryId);
#[derive(
Wrapper,
Debug,
Clone,
Copy,
Serialize,
Deserialize,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
From,
Display,
)]
#[display(inner)]
#[wrapper(Deref)]
pub struct ReviewId(EntryId);
pub type RevisionIx = usize;
#[derive(Debug, Error)]
pub enum Error {
#[error("causal dependency {0:?} missing")]
Missing(EntryId),
#[error("thread apply failed: {0}")]
Thread(#[from] thread::Error),
#[error("identity doc failed to load: {0}")]
Doc(#[from] DocError),
#[error("missing identity document")]
MissingIdentity,
#[error("empty review; verdict or summary not provided")]
EmptyReview,
#[error("review {0} of {1} already exists by author {2}")]
DuplicateReview(ReviewId, RevisionId, NodeId),
#[error("payload failed to load: {0}")]
Payload(#[from] PayloadError),
#[error("git: {0}")]
Git(#[from] git::raw::Error),
#[error("store: {0}")]
Store(#[from] store::Error),
#[error("op decoding failed: {0}")]
Op(#[from] op::OpEncodingError),
#[error("{0} not authorized to apply {1:?}")]
NotAuthorized(ActorId, Box<Action>),
#[error("action is not allowed: {0}")]
NotAllowed(EntryId),
#[error("revision not found: {0}")]
RevisionNotFound(RevisionId),
#[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>,
},
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Action {
#[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,
},
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reply_to: Option<CommentId>,
#[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 },
#[serde(rename = "review.react")]
ReviewReact {
review: ReviewId,
reaction: Reaction,
active: bool,
},
#[serde(rename = "revision")]
Revision {
description: String,
base: git::Oid,
oid: git::Oid,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
resolves: BTreeSet<(EntryId, CommentId)>,
},
#[serde(rename = "revision.edit")]
RevisionEdit {
revision: RevisionId,
description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
embeds: Vec<Embed<Uri>>,
},
#[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 {
revision: RevisionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
location: Option<CodeLocation>,
body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
reply_to: Option<CommentId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
embeds: Vec<Embed<Uri>>,
},
#[serde(rename = "revision.comment.edit")]
RevisionCommentEdit {
revision: RevisionId,
comment: CommentId,
body: String,
embeds: Vec<Embed<Uri>>,
},
#[serde(rename = "revision.comment.redact")]
RevisionCommentRedact {
revision: RevisionId,
comment: CommentId,
},
#[serde(rename = "revision.comment.react")]
RevisionCommentReact {
revision: RevisionId,
comment: CommentId,
reaction: Reaction,
active: bool,
},
#[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 { .. }
)
}
}
#[derive(Debug)]
#[must_use]
pub struct Merged<'a, R> {
pub patch: PatchId,
pub entry: EntryId,
stored: &'a R,
}
impl<R: WriteRepository> Merged<'_, R> {
pub fn cleanup<G>(
self,
working: &git::raw::Repository,
signer: &Device<G>,
) -> Result<(), storage::RepositoryError>
where
G: crypto::signature::Signer<crypto::Signature>,
{
let nid = signer.public_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(())
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MergeTarget {
#[default]
Delegates,
}
impl MergeTarget {
pub fn head<R: ReadRepository>(&self, repo: &R) -> Result<git::Oid, RepositoryError> {
match self {
MergeTarget::Delegates => {
let (_, target) = repo.head()?;
Ok(target)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Patch {
pub(super) title: cob::Title,
pub(super) author: Author,
pub(super) state: State,
pub(super) target: MergeTarget,
pub(super) labels: BTreeSet<Label>,
pub(super) merges: BTreeMap<ActorId, Merge>,
pub(super) revisions: BTreeMap<RevisionId, Option<Revision>>,
pub(super) assignees: BTreeSet<ActorId>,
pub(super) timeline: Vec<EntryId>,
pub(super) reviews: BTreeMap<ReviewId, Option<(RevisionId, ActorId)>>,
}
impl Patch {
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(),
}
}
pub fn title(&self) -> &str {
self.title.as_ref()
}
pub fn state(&self) -> &State {
&self.state
}
pub fn target(&self) -> MergeTarget {
self.target
}
pub fn timestamp(&self) -> Timestamp {
self.updates()
.next()
.map(|(_, r)| r)
.expect("Patch::timestamp: at least one revision is present")
.timestamp
}
pub fn labels(&self) -> impl Iterator<Item = &Label> {
self.labels.iter()
}
pub fn description(&self) -> &str {
let (_, r) = self.root();
r.description()
}
pub fn embeds(&self) -> &[Embed<Uri>] {
let (_, r) = self.root();
r.embeds()
}
pub fn author(&self) -> &Author {
&self.author
}
pub fn authors(&self) -> BTreeSet<&Author> {
self.revisions
.values()
.filter_map(|r| r.as_ref())
.map(|r| &r.author)
.collect()
}
pub fn revision(&self, id: &RevisionId) -> Option<&Revision> {
self.revisions.get(id).and_then(|o| o.as_ref())
}
pub fn updates(&self) -> impl DoubleEndedIterator<Item = (RevisionId, &Revision)> {
self.revisions_by(self.author().public_key())
}
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))
})
}
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)
}
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
}
})
})
}
pub fn assignees(&self) -> impl Iterator<Item = Did> + '_ {
self.assignees.iter().map(Did::from)
}
pub fn merges(&self) -> impl Iterator<Item = (&ActorId, &Merge)> {
self.merges.iter()
}
pub fn head(&self) -> &git::Oid {
&self.latest().1.oid
}
pub fn base(&self) -> &git::Oid {
&self.latest().1.base
}
pub fn merge_base<R: ReadRepository>(
&self,
repo: &R,
) -> Result<crate::git::Oid, crate::git::raw::Error> {
repo.merge_base(self.base(), self.head())
}
pub fn range(&self) -> Result<(crate::git::Oid, crate::git::Oid), crate::git::raw::Error> {
Ok((*self.base(), *self.head()))
}
pub fn version(&self) -> RevisionIx {
self.revisions
.len()
.checked_sub(1)
.expect("Patch::version: at least one revision is present")
}
pub fn root(&self) -> (RevisionId, &Revision) {
self.updates()
.next()
.expect("Patch::root: there is always a root revision")
}
pub fn latest(&self) -> (RevisionId, &Revision) {
self.latest_by(self.author().public_key())
.expect("Patch::latest: there is always at least one revision")
}
pub fn latest_by<'a>(&'a self, author: &'a PublicKey) -> Option<(RevisionId, &'a Revision)> {
self.revisions_by(author).next_back()
}
pub fn updated_at(&self) -> Timestamp {
self.latest().1.timestamp()
}
pub fn is_merged(&self) -> bool {
matches!(self.state(), State::Merged { .. })
}
pub fn is_open(&self) -> bool {
matches!(self.state(), State::Open { .. })
}
pub fn is_archived(&self) -> bool {
matches!(self.state(), State::Archived)
}
pub fn is_draft(&self) -> bool {
matches!(self.state(), State::Draft)
}
pub fn authorization(
&self,
action: &Action,
actor: &ActorId,
doc: &Doc,
) -> Result<Authorization, Error> {
if doc.is_delegate(&actor.into()) {
return Ok(Authorization::Allow);
}
let author = self.author().id().as_key();
let outcome = match action {
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,
}),
Action::Label { labels } => {
if labels == &self.labels {
Authorization::Allow
} else {
Authorization::Deny
}
}
Action::Assign { .. } => Authorization::Deny,
Action::Merge { .. } => match self.target() {
MergeTarget::Delegates => Authorization::Deny,
},
Action::Review { .. } => Authorization::Allow,
Action::ReviewRedact { review, .. } => {
if let Some((_, review)) = lookup::review(self, review)? {
Authorization::from(actor == review.author.public_key())
} else {
Authorization::Unknown
}
}
Action::ReviewEdit(edit) => {
if let Some((_, review)) = lookup::review(self, edit.review_id())? {
Authorization::from(actor == review.author.public_key())
} else {
Authorization::Unknown
}
}
Action::ReviewComment { .. } => Authorization::Allow,
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()));
}
}
Authorization::Unknown
}
Action::ReviewCommentReact { .. } => Authorization::Allow,
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(),
));
}
}
Authorization::Unknown
}
Action::ReviewReact { .. } => Authorization::Allow,
Action::Revision { .. } => Authorization::Allow,
Action::RevisionEdit { revision, .. } | Action::RevisionRedact { revision, .. } => {
if let Some(revision) = lookup::revision(self, revision)? {
Authorization::from(actor == revision.author.public_key())
} else {
Authorization::Unknown
}
}
Action::RevisionReact { .. } => Authorization::Allow,
Action::RevisionComment { .. } => Authorization::Allow,
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()));
}
}
Authorization::Unknown
}
Action::RevisionCommentReact { .. } => Authorization::Allow,
};
Ok(outcome)
}
}
impl Patch {
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 => {
Ok(())
}
}
}
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 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 } => {
let (root, _) = self.root();
if revision == root {
return Err(Error::NotAllowed(entry));
}
if let Some(r) = self.revisions.get_mut(&revision) {
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 {
return Ok(());
};
if let Some(rev) = rev {
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,
));
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 } => {
let Some(locator) = self.reviews.get_mut(&review) else {
return Err(Error::Missing(review.into_inner()));
};
let Some((revision, reviewer)) = locator else {
return Ok(());
};
let Some(redactable) = self.revisions.get_mut(revision) else {
return Err(Error::Missing(revision.into_inner()));
};
let Some(revision) = redactable else {
return Ok(());
};
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
);
}
*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 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());
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
},
);
merges.retain(|_, count| *count >= identity.threshold());
match merges.into_keys().collect::<Vec<_>>().as_slice() {
[] => {
}
[(revision, commit)] => {
self.state = State::Merged {
revision: *revision,
commit: *commit,
};
}
revisions => {
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 => {
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)),
Some(None) => Ok(None),
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)),
Some(None) => Ok(None),
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) => {
Ok(None)
}
None => Err(Error::Missing(revision.into_inner())),
}
}
Some(None) => {
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) => {
Ok(None)
}
None => Err(Error::Missing(revision.into_inner())),
}
}
Some(None) => {
Ok(None)
}
None => Err(Error::Missing(review.into_inner())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Revision {
pub(super) id: RevisionId,
pub(super) author: Author,
pub(super) description: NonEmpty<Edit>,
pub(super) base: git::Oid,
pub(super) oid: git::Oid,
pub(super) discussion: Thread<Comment<CodeLocation>>,
pub(super) reviews: BTreeMap<ActorId, Review>,
pub(super) timestamp: Timestamp,
pub(super) resolves: BTreeSet<(EntryId, CommentId)>,
#[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
}
pub fn author(&self) -> &Author {
&self.author
}
pub fn base(&self) -> &git::Oid {
&self.base
}
pub fn head(&self) -> git::Oid {
self.oid
}
pub fn range(&self) -> (git::Oid, git::Oid) {
(self.base, self.oid)
}
pub fn timestamp(&self) -> Timestamp {
self.timestamp
}
pub fn discussion(&self) -> &Thread<Comment<CodeLocation>> {
&self.discussion
}
pub fn resolves(&self) -> &BTreeSet<(EntryId, CommentId)> {
&self.resolves
}
pub fn replies(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment<CodeLocation>)> {
self.discussion.comments()
}
pub fn reviews(&self) -> impl DoubleEndedIterator<Item = (&PublicKey, &Review)> {
self.reviews.iter()
}
pub fn review_by(&self, author: &ActorId) -> Option<&Review> {
self.reviews.get(author)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "status")]
pub enum State {
Draft,
Open {
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
conflicts: Vec<(RevisionId, git::Oid)>,
},
Archived,
Merged {
revision: RevisionId,
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,
}
}
}
#[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"),
}
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "status")]
pub enum Lifecycle {
#[default]
Open,
Draft,
Archived,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "camelCase")]
pub struct Merge {
pub revision: RevisionId,
pub commit: git::Oid,
pub timestamp: Timestamp,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Verdict {
Accept,
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"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(from = "encoding::review::Review")]
pub struct Review {
pub(super) id: ReviewId,
pub(super) author: Author,
pub(super) verdict: Option<Verdict>,
pub(super) summary: NonEmpty<Edit>,
pub(super) comments: Thread<Comment<CodeLocation>>,
pub(super) labels: Vec<Label>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub(super) reactions: Reactions,
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,
}
}
pub fn id(&self) -> ReviewId {
self.id
}
pub fn author(&self) -> &Author {
&self.author
}
pub fn verdict(&self) -> Option<Verdict> {
self.verdict
}
pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&EntryId, &Comment<CodeLocation>)> {
self.comments.comments()
}
pub fn labels(&self) -> impl Iterator<Item = &Label> {
self.labels.iter()
}
pub fn summary(&self) -> &str {
self.summary.last().body.as_str()
}
pub fn embeds(&self) -> &[Embed<Uri>] {
&self.summary.last().embeds
}
pub fn reactions(&self) -> &Reactions {
&self.reactions
}
pub fn edits(&self) -> impl Iterator<Item = &Edit> {
self.summary.iter()
}
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,
})
}
pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> {
self.push(Action::RevisionRedact { revision })
}
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![],
})
}
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,
})
}
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,
})
}
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,
})
}
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,
})
}
pub fn comment_redact(
&mut self,
revision: RevisionId,
comment: CommentId,
) -> Result<(), store::Error> {
self.push(Action::RevisionCommentRedact { revision, comment })
}
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,
})
}
pub fn review_comment_resolve(
&mut self,
review: ReviewId,
comment: CommentId,
) -> Result<(), store::Error> {
self.push(Action::ReviewCommentResolve { review, comment })
}
pub fn review_comment_unresolve(
&mut self,
review: ReviewId,
comment: CommentId,
) -> Result<(), store::Error> {
self.push(Action::ReviewCommentUnresolve { 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,
})
}
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,
})
}
pub fn redact_review_comment(
&mut self,
review: ReviewId,
comment: EntryId,
) -> Result<(), store::Error> {
self.push(Action::ReviewCommentRedact { review, comment })
}
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,
})
}
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(),
)))
}
pub fn review_react(
&mut self,
review: ReviewId,
reaction: Reaction,
active: bool,
) -> Result<(), store::Error> {
self.push(Action::ReviewReact {
review,
reaction,
active,
})
}
pub fn redact_review(&mut self, review: ReviewId) -> Result<(), store::Error> {
self.push(Action::ReviewRedact { review })
}
pub fn merge(&mut self, revision: RevisionId, commit: git::Oid) -> Result<(), store::Error> {
self.push(Action::Merge { revision, commit })
}
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(),
})
}
pub fn lifecycle(&mut self, state: Lifecycle) -> Result<(), store::Error> {
self.push(Action::Lifecycle { state })
}
pub fn assign(&mut self, assignees: BTreeSet<Did>) -> Result<(), store::Error> {
self.push(Action::Assign { assignees })
}
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, 'g, R, C> {
pub id: ObjectId,
patch: Patch,
store: &'g mut Patches<'a, R>,
cache: &'g mut C,
}
impl<'a, 'g, R, C> PatchMut<'a, 'g, R, C>
where
C: cob::cache::Update<Patch>,
R: ReadRepository + SignRepository + cob::Store<Namespace = NodeId>,
{
pub fn new(id: ObjectId, patch: Patch, cache: &'g mut Cache<Patches<'a, R>, C>) -> Self {
Self {
id,
patch,
store: &mut cache.store,
cache: &mut cache.cache,
}
}
pub fn id(&self) -> &ObjectId {
&self.id
}
pub fn reload(&mut self) -> Result<(), store::Error> {
self.patch = self
.store
.get(&self.id)?
.ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?;
Ok(())
}
pub fn transaction<G, F>(
&mut self,
message: &str,
signer: &Device<G>,
operations: F,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
F: FnOnce(&mut Transaction<Patch, R>) -> Result<(), store::Error>,
{
let mut tx = Transaction::default();
operations(&mut tx)?;
let (patch, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
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)
}
pub fn edit<G, S>(
&mut self,
title: cob::Title,
target: MergeTarget,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
S: ToString,
{
self.transaction("Edit", signer, |tx| tx.edit(title, target))
}
pub fn edit_revision<G, S>(
&mut self,
revision: RevisionId,
description: S,
embeds: impl IntoIterator<Item = Embed<Uri>>,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
S: ToString,
{
self.transaction("Edit revision", signer, |tx| {
tx.edit_revision(revision, description, embeds.into_iter().collect())
})
}
pub fn redact<G>(&mut self, revision: RevisionId, signer: &Device<G>) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Redact revision", signer, |tx| tx.redact(revision))
}
pub fn thread<G, S>(
&mut self,
revision: RevisionId,
body: S,
signer: &Device<G>,
) -> Result<CommentId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
S: ToString,
{
self.transaction("Create thread", signer, |tx| tx.thread(revision, body))
}
pub fn comment<G, S>(
&mut self,
revision: RevisionId,
body: S,
reply_to: Option<CommentId>,
location: Option<CodeLocation>,
embeds: impl IntoIterator<Item = Embed<Uri>>,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
S: ToString,
{
self.transaction("Comment", signer, |tx| {
tx.comment(
revision,
body,
reply_to,
location,
embeds.into_iter().collect(),
)
})
}
pub fn react<G>(
&mut self,
revision: RevisionId,
reaction: Reaction,
location: Option<CodeLocation>,
active: bool,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("React", signer, |tx| {
tx.react(revision, reaction, location, active)
})
}
pub fn comment_edit<G, S>(
&mut self,
revision: RevisionId,
comment: CommentId,
body: S,
embeds: impl IntoIterator<Item = Embed<Uri>>,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
S: ToString,
{
self.transaction("Edit comment", signer, |tx| {
tx.comment_edit(revision, comment, body, embeds.into_iter().collect())
})
}
pub fn comment_react<G>(
&mut self,
revision: RevisionId,
comment: CommentId,
reaction: Reaction,
active: bool,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("React comment", signer, |tx| {
tx.comment_react(revision, comment, reaction, active)
})
}
pub fn comment_redact<G>(
&mut self,
revision: RevisionId,
comment: CommentId,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Redact comment", signer, |tx| {
tx.comment_redact(revision, comment)
})
}
pub fn review_comment<G, S>(
&mut self,
review: ReviewId,
body: S,
location: Option<CodeLocation>,
reply_to: Option<CommentId>,
embeds: impl IntoIterator<Item = Embed<Uri>>,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
S: ToString,
{
self.transaction("Review comment", signer, |tx| {
tx.review_comment(
review,
body,
location,
reply_to,
embeds.into_iter().collect(),
)
})
}
pub fn edit_review_comment<G, S>(
&mut self,
review: ReviewId,
comment: EntryId,
body: S,
embeds: impl IntoIterator<Item = Embed<Uri>>,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
S: ToString,
{
self.transaction("Edit review comment", signer, |tx| {
tx.edit_review_comment(review, comment, body, embeds.into_iter().collect())
})
}
pub fn react_review_comment<G>(
&mut self,
review: ReviewId,
comment: EntryId,
reaction: Reaction,
active: bool,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("React to review comment", signer, |tx| {
tx.react_review_comment(review, comment, reaction, active)
})
}
pub fn redact_review_comment<G>(
&mut self,
review: ReviewId,
comment: EntryId,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Redact review comment", signer, |tx| {
tx.redact_review_comment(review, comment)
})
}
pub fn review<G>(
&mut self,
revision: RevisionId,
verdict: Option<Verdict>,
summary: Option<String>,
labels: Vec<Label>,
signer: &Device<G>,
) -> Result<ReviewId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
if verdict.is_none() && summary.is_none() {
return Err(Error::EmptyReview);
}
self.transaction("Review", signer, |tx| {
tx.review(revision, verdict, summary, labels)
})
.map(ReviewId)
}
pub fn review_edit<G>(
&mut self,
review: ReviewId,
verdict: Option<Verdict>,
summary: String,
labels: Vec<Label>,
embeds: impl IntoIterator<Item = Embed<Uri>>,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Edit review", signer, |tx| {
tx.review_edit(review, verdict, summary, labels, embeds)
})
}
pub fn review_react<G>(
&mut self,
review: ReviewId,
reaction: Reaction,
active: bool,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("React to review", signer, |tx| {
tx.review_react(review, reaction, active)
})
}
pub fn redact_review<G>(
&mut self,
review: ReviewId,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Redact review", signer, |tx| tx.redact_review(review))
}
pub fn resolve_review_comment<G>(
&mut self,
review: ReviewId,
comment: CommentId,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Resolve review comment", signer, |tx| {
tx.review_comment_resolve(review, comment)
})
}
pub fn unresolve_review_comment<G>(
&mut self,
review: ReviewId,
comment: CommentId,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Unresolve review comment", signer, |tx| {
tx.review_comment_unresolve(review, comment)
})
}
pub fn merge<G>(
&mut self,
revision: RevisionId,
commit: git::Oid,
signer: &Device<G>,
) -> Result<Merged<'_, R>, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
let entry = self.transaction("Merge revision", signer, |tx| tx.merge(revision, commit))?;
Ok(Merged {
entry,
patch: self.id,
stored: self.store.as_ref(),
})
}
pub fn update<G>(
&mut self,
description: impl ToString,
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
signer: &Device<G>,
) -> Result<RevisionId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Add revision", signer, |tx| {
tx.revision(description, base, oid)
})
.map(RevisionId)
}
pub fn lifecycle<G>(&mut self, state: Lifecycle, signer: &Device<G>) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state))
}
pub fn assign<G>(
&mut self,
assignees: BTreeSet<Did>,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Assign", signer, |tx| tx.assign(assignees))
}
pub fn archive<G>(&mut self, signer: &Device<G>) -> Result<bool, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.lifecycle(Lifecycle::Archived, signer)?;
Ok(true)
}
pub fn unarchive<G>(&mut self, signer: &Device<G>) -> Result<bool, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
if !self.is_archived() {
return Ok(false);
}
self.lifecycle(Lifecycle::Open, signer)?;
Ok(true)
}
pub fn ready<G>(&mut self, signer: &Device<G>) -> Result<bool, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
if !self.is_draft() {
return Ok(false);
}
self.lifecycle(Lifecycle::Open, signer)?;
Ok(true)
}
pub fn unready<G>(&mut self, signer: &Device<G>) -> Result<bool, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
if !matches!(self.state(), State::Open { conflicts } if conflicts.is_empty()) {
return Ok(false);
}
self.lifecycle(Lifecycle::Draft, signer)?;
Ok(true)
}
pub fn label<G>(
&mut self,
labels: impl IntoIterator<Item = Label>,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Label", signer, |tx| tx.label(labels))
}
}
impl<R, C> Deref for PatchMut<'_, '_, R, C> {
type Target = Patch;
fn deref(&self) -> &Self::Target {
&self.patch
}
}
#[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 {
pub fn total(&self) -> usize {
self.open + self.draft + self.archived + self.merged
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct ByRevision {
pub id: PatchId,
pub patch: Patch,
pub revision_id: RevisionId,
pub revision: Revision,
}
pub struct Patches<'a, R> {
raw: store::Store<'a, Patch, R>,
}
impl<'a, R> Deref for Patches<'a, R> {
type Target = store::Store<'a, Patch, R>;
fn deref(&self) -> &Self::Target {
&self.raw
}
}
impl<R> HasRepoId for Patches<'_, R>
where
R: ReadRepository,
{
fn rid(&self) -> RepoId {
self.as_ref().id()
}
}
impl<'a, R> Patches<'a, R>
where
R: ReadRepository + cob::Store<Namespace = NodeId>,
{
pub fn open(repository: &'a R) -> Result<Self, RepositoryError> {
let identity = repository.identity_head()?;
let raw = store::Store::open(repository)?.identity(identity);
Ok(Self { raw })
}
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)
}
pub fn find_by_revision(&self, revision: &RevisionId) -> Result<Option<ByRevision>, Error> {
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)
}
pub fn get(&self, id: &ObjectId) -> Result<Option<Patch>, store::Error> {
self.raw.get(id)
}
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()))
}
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, R> Patches<'a, R>
where
R: ReadRepository + SignRepository + cob::Store<Namespace = NodeId>,
{
pub fn create<'g, C, G>(
&'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 C,
signer: &Device<G>,
) -> Result<PatchMut<'a, 'g, R, C>, Error>
where
C: cob::cache::Update<Patch>,
G: crypto::signature::Signer<crypto::Signature>,
{
self._create(
title,
description,
target,
base,
oid,
labels,
Lifecycle::default(),
cache,
signer,
)
}
pub fn draft<'g, C, G>(
&'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 C,
signer: &Device<G>,
) -> Result<PatchMut<'a, 'g, R, C>, Error>
where
C: cob::cache::Update<Patch>,
G: crypto::signature::Signer<crypto::Signature>,
{
self._create(
title,
description,
target,
base,
oid,
labels,
Lifecycle::Draft,
cache,
signer,
)
}
pub fn get_mut<'g, C>(
&'g mut self,
id: &ObjectId,
cache: &'g mut C,
) -> Result<PatchMut<'a, 'g, R, C>, 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,
})
}
fn _create<'g, C, G>(
&'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 C,
signer: &Device<G>,
) -> Result<PatchMut<'a, 'g, R, C>, Error>
where
C: cob::cache::Update<Patch>,
G: crypto::signature::Signer<crypto::Signature>,
{
let (id, patch) = Transaction::initial("Create patch", &mut self.raw, signer, |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,
})
}
}
#[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
}
}
mod ser {
use std::collections::{BTreeMap, BTreeSet};
use serde::ser::SerializeSeq;
use crate::cob::{thread::Reactions, ActorId, CodeLocation};
#[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
},
)
}
}
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()
}
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::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).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,
&[],
&alice.signer,
)
.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).unwrap();
let patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
&alice.signer,
)
.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, [], &alice.signer)
.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).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
&alice.signer,
)
.unwrap();
let id = patch.id;
let (rid, _) = patch.revisions().next().unwrap();
let _merge = patch.merge(rid, branch.base, &alice.signer).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).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
&alice.signer,
)
.unwrap();
let (revision_id, _) = patch.latest();
let review_id = patch
.review(
revision_id,
Some(Verdict::Accept),
Some("LGTM".to_owned()),
vec![],
&alice.signer,
)
.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, &alice.signer).unwrap();
patch.reload().unwrap();
let (_, revision) = patch.latest();
assert_eq!(revision.reviews().count(), 0);
patch.redact_review(review_id, &alice.signer).unwrap();
patch
.redact_review(ReviewId(arbitrary::entry_id()), &alice.signer)
.unwrap_err();
}
#[test]
fn test_patch_review_revision_redact() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Cache::no_cache(&*alice.repo).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
&alice.signer,
)
.unwrap();
let update = checkout.branch_with([("README", b"Hello Radicle!")]);
let updated = patch
.update("I've made changes.", branch.base, update.oid, &alice.signer)
.unwrap();
let review = patch
.review(updated, Some(Verdict::Accept), None, vec![], &alice.signer)
.unwrap();
patch.redact(updated, &alice.signer).unwrap();
patch.redact_review(review, &alice.signer).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 = gen::<RepoId>(1);
let doc = RawDoc::new(
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 = 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 = 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).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
&alice.signer,
)
.unwrap();
let (rid, _) = patch.latest();
let review = patch
.review(
rid,
Some(Verdict::Accept),
Some("LGTM".to_owned()),
vec![],
&alice.signer,
)
.unwrap();
patch
.review_edit(
review,
Some(Verdict::Reject),
"Whoops!".to_owned(),
vec![],
vec![],
&alice.signer,
)
.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.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).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
&alice.signer,
)
.unwrap();
let (rid, _) = patch.latest();
patch
.review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
.unwrap();
patch
.review(rid, Some(Verdict::Reject), None, vec![], &alice.signer)
.unwrap();
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).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
&alice.signer,
)
.unwrap();
let (rid, _) = patch.latest();
let review = patch
.review(rid, Some(Verdict::Accept), None, vec![], &alice.signer)
.unwrap();
patch
.review_comment(review, "First comment!", None, None, [], &alice.signer)
.unwrap();
let _review = patch
.review_edit(
review,
Some(Verdict::Reject),
"".to_string(),
vec![],
vec![],
&alice.signer,
)
.unwrap();
patch
.review_comment(review, "Second comment!", None, None, [], &alice.signer)
.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().nth(0).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).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
&alice.signer,
)
.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![], &alice.signer)
.unwrap();
patch
.review_comment(
review,
"I like these lines of code",
Some(location.clone()),
None,
[],
&alice.signer,
)
.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).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
&alice.signer,
)
.unwrap();
let (rid, _) = patch.latest();
let review = patch
.review(
rid,
Some(Verdict::Accept),
Some("Nah".to_owned()),
vec![],
&alice.signer,
)
.unwrap();
patch
.review_edit(
review,
Some(Verdict::Accept),
"".to_string(),
vec![],
vec![],
&alice.signer,
)
.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).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,
&[],
&alice.signer,
)
.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, &alice.signer)
.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().nth(0).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).unwrap();
let mut patch = patches
.create(
cob::Title::new("My first patch").unwrap(),
"Blah blah blah.",
MergeTarget::Delegates,
branch.base,
branch.oid,
&[],
&alice.signer,
)
.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, &alice.signer)
.unwrap();
assert_eq!(patch.revisions().count(), 2);
patch.redact(revision_id, &alice.signer).unwrap();
assert_eq!(patch.latest().0, RevisionId(*patch_id));
assert_eq!(patch.revisions().count(), 1);
assert_eq!(patch.latest(), patch.root());
assert!(patch.redact(patch.latest().0, &alice.signer).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 },
})
);
}
}