Set patch status to `merged` automatically

When a merge is applied to a patch, we check whether the threshold
of delegates has been reached, at which point we set the patch status
to `merged`.

This requires threading the identity document oid through the "apply"
process.
This commit is contained in:
Alexis Sellier 2023-04-03 13:33:43 +02:00
parent 6b44955cd9
commit 33461e4be1
No known key found for this signature in database
17 changed files with 248 additions and 81 deletions

View File

@ -76,7 +76,7 @@ Fast-forward
The patch is now merged and closed :).
```
$ rad patch
$ rad patch --merged
╭───────────────────────────────────────────────────────────────────────────────────────────────╮
│ Define power requirements a07ef77 R2 f6484e0 (flux-capacitor-power, master) ahead 3, behind 0 │
├───────────────────────────────────────────────────────────────────────────────────────────────┤

View File

@ -17,6 +17,7 @@ use std::ffi::OsString;
use anyhow::anyhow;
use radicle::cob::patch;
use radicle::cob::patch::PatchId;
use radicle::{prelude::*, Node};
@ -34,7 +35,7 @@ pub const HELP: Help = Help {
Usage
rad patch
rad patch list
rad patch list [--all|--merged|--open|--archived]
rad patch show <id>
rad patch open [<option>...]
rad patch update <id> [<option>...]
@ -48,7 +49,14 @@ Create/Update options
-m, --message [<string>] Provide a comment message to the patch or revision (default: prompt)
--no-message Leave the patch or revision comment message blank
Options
List options
--all Show all patches, including merged and archived patches
--archived Show only archived patches
--merged Show only merged patches
--open Show only open patches (default)
Other options
--help Print help
"#,
@ -83,7 +91,9 @@ pub enum Operation {
Checkout {
patch_id: Rev,
},
List,
List {
filter: Option<patch::State>,
},
}
#[derive(Debug)]
@ -107,6 +117,7 @@ impl Args for Options {
let mut patch_id = None;
let mut message = Message::default();
let mut push = true;
let mut filter = Some(patch::State::Open);
while let Some(arg) = parser.next()? {
match arg {
@ -140,6 +151,20 @@ impl Args for Options {
push = false;
}
// List options.
Long("all") => {
filter = None;
}
Long("archived") => {
filter = Some(patch::State::Archived);
}
Long("merged") => {
filter = Some(patch::State::Merged);
}
Long("open") => {
filter = Some(patch::State::Open);
}
// Common.
Long("verbose") | Short('v') => {
verbose = true;
@ -169,7 +194,7 @@ impl Args for Options {
let op = match op.unwrap_or_default() {
OperationName::Open => Operation::Open { message },
OperationName::List => Operation::List,
OperationName::List => Operation::List { filter },
OperationName::Show => Operation::Show {
patch_id: patch_id.ok_or_else(|| anyhow!("a patch id must be provided"))?,
},
@ -210,8 +235,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
Operation::Open { ref message } => {
create::run(&repository, &profile, &workdir, message.clone(), options)?;
}
Operation::List => {
list::run(&repository, &profile, Some(workdir))?;
Operation::List { filter } => {
list::run(&repository, &profile, Some(workdir), filter)?;
}
Operation::Show { patch_id } => {
let patch_id = patch_id.resolve(&repository.backend)?;

View File

@ -1,5 +1,6 @@
use anyhow::anyhow;
use radicle::cob::patch;
use radicle::cob::patch::{Patch, PatchId, Patches, Revision, Verdict};
use radicle::git;
use radicle::prelude::*;
@ -16,17 +17,25 @@ pub fn run(
repository: &Repository,
profile: &Profile,
workdir: Option<git::raw::Repository>,
filter: Option<patch::State>,
) -> anyhow::Result<()> {
let me = *profile.id();
let patches = Patches::open(repository)?;
let proposed = patches.proposed()?;
let all = patches.all()?;
// Patches the user authored.
let mut own = Vec::new();
// Patches other users authored.
let mut other = Vec::new();
for (id, patch, _) in proposed {
for patch in all {
let (id, patch, _) = patch?;
if let Some(filter) = filter {
if patch.state() != filter {
continue;
}
}
if patch.author().id().as_key() == &me {
own.push((id, patch));
} else {

View File

@ -101,6 +101,7 @@ mod routes {
"open": 1,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 1,

View File

@ -751,6 +751,7 @@ mod routes {
"open": 1,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 1,
@ -781,6 +782,7 @@ mod routes {
"open": 1,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 1,
@ -2267,7 +2269,7 @@ mod routes {
},
"title": "A new `hello world`",
"description": "change `hello world` in README to something else",
"state": { "status": "open" },
"state": { "status": "merged" },
"target": "delegates",
"tags": [],
"revisions": [

View File

@ -16,7 +16,7 @@ use crate::{
store::{self, FromHistory as _, Transaction},
},
identity::{doc::DocError, Did, Identity, IdentityError},
prelude::Doc,
prelude::{Doc, ReadRepository},
storage::{git as storage, RemoteId, WriteRepository},
};
@ -307,7 +307,11 @@ impl store::FromHistory for Proposal {
&*TYPENAME
}
fn apply(&mut self, ops: impl IntoIterator<Item = Op>) -> Result<(), Self::Error> {
fn apply<R: ReadRepository>(
&mut self,
ops: impl IntoIterator<Item = Op>,
repo: &R,
) -> Result<(), Self::Error> {
for op in ops {
let id = op.id;
let author = Author::new(op.author);
@ -358,15 +362,17 @@ impl store::FromHistory for Proposal {
}
Action::Thread { revision, action } => match self.revisions.get_mut(&revision) {
Some(Redactable::Present(revision)) => {
revision.discussion.apply([cob::Op::new(
Some(Redactable::Present(revision)) => revision.discussion.apply(
[cob::Op::new(
op.id,
action,
op.author,
op.timestamp,
op.clock,
)])?
}
op.identity,
)],
repo,
)?,
Some(Redactable::Redacted) => return Err(ApplyError::Redacted(revision)),
None => return Err(ApplyError::Missing(revision)),
},
@ -578,7 +584,7 @@ impl<'a, 'g> ProposalMut<'a, 'g> {
operations(&mut tx)?;
let (ops, clock, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
self.proposal.apply(ops)?;
self.proposal.apply(ops, self.store.as_ref())?;
self.clock = clock;
Ok(commit)

View File

@ -16,7 +16,7 @@ use crate::cob::thread;
use crate::cob::thread::{CommentId, Thread};
use crate::cob::{store, ActorId, EntryId, ObjectId, TypeName};
use crate::crypto::Signer;
use crate::prelude::Did;
use crate::prelude::{Did, ReadRepository};
use crate::storage::git as storage;
/// Issue operation.
@ -122,7 +122,11 @@ impl store::FromHistory for Issue {
&*TYPENAME
}
fn apply(&mut self, ops: impl IntoIterator<Item = Op>) -> Result<(), Error> {
fn apply<R: ReadRepository>(
&mut self,
ops: impl IntoIterator<Item = Op>,
repo: &R,
) -> Result<(), Error> {
for op in ops {
match op.action {
Action::Assign { add, remove } => {
@ -148,13 +152,17 @@ impl store::FromHistory for Issue {
}
}
Action::Thread { action } => {
self.thread.apply([cob::Op::new(
op.id,
action,
op.author,
op.timestamp,
op.clock,
)])?;
self.thread.apply(
[cob::Op::new(
op.id,
action,
op.author,
op.timestamp,
op.clock,
op.identity,
)],
repo,
)?;
}
}
}
@ -389,7 +397,7 @@ impl<'a, 'g> IssueMut<'a, 'g> {
operations(&mut tx)?;
let (ops, clock, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
self.issue.apply(ops)?;
self.issue.apply(ops, self.store.as_ref())?;
self.clock = clock;
Ok(commit)

View File

@ -6,6 +6,8 @@ use radicle_crdt::clock;
use radicle_crdt::clock::Lamport;
use radicle_crypto::PublicKey;
use crate::git;
/// The author of an [`Op`].
pub type ActorId = PublicKey;
@ -34,6 +36,8 @@ pub struct Op<A> {
pub clock: Lamport,
/// Timestamp of this operation.
pub timestamp: clock::Physical,
/// Head of identity document committed to by this operation.
pub identity: git::Oid,
}
impl<A: Eq> PartialOrd for Op<A> {
@ -55,6 +59,7 @@ impl<A> Op<A> {
author: ActorId,
timestamp: impl Into<clock::Physical>,
clock: Lamport,
identity: git::Oid,
) -> Self {
Self {
id,
@ -62,6 +67,7 @@ impl<A> Op<A> {
author,
clock,
timestamp: timestamp.into(),
identity,
}
}
@ -80,6 +86,7 @@ where
fn try_from(entry: &'a EntryWithClock) -> Result<Self, Self::Error> {
let id = *entry.id();
let identity = entry.resource();
let ops = entry
.changes()
.map(|blob| {
@ -90,6 +97,7 @@ where
author: *entry.actor(),
clock: entry.clock().into(),
timestamp: entry.timestamp().into(),
identity,
};
Ok::<_, Self::Error>(op)
})

View File

@ -23,6 +23,7 @@ use crate::cob::thread::Thread;
use crate::cob::{store, ActorId, EntryId, ObjectId, TypeName};
use crate::crypto::{PublicKey, Signer};
use crate::git;
use crate::identity::doc::DocError;
use crate::prelude::*;
use crate::storage::git as storage;
@ -60,6 +61,9 @@ pub enum ApplyError {
/// Error applying an op to the patch thread.
#[error("thread apply failed: {0}")]
Thread(#[from] thread::OpError),
/// Error loading the identity document committed to by an operation.
#[error("identity doc failed to load: {0}")]
Doc(#[from] DocError),
}
/// Error updating or creating patches.
@ -275,7 +279,11 @@ impl store::FromHistory for Patch {
&*TYPENAME
}
fn apply(&mut self, ops: impl IntoIterator<Item = Op>) -> Result<(), ApplyError> {
fn apply<R: ReadRepository>(
&mut self,
ops: impl IntoIterator<Item = Op>,
repo: &R,
) -> Result<(), ApplyError> {
for op in ops {
let id = op.id;
let author = Author::new(op.author);
@ -376,6 +384,16 @@ impl store::FromHistory for Patch {
.into(),
op.clock,
);
let doc = repo.identity_doc_at(op.identity)?;
if revision
.merges()
.filter(|m| doc.is_delegate(&m.node))
.count()
>= doc.threshold
{
self.state.set(State::Merged, op.clock);
}
} else {
return Err(ApplyError::Missing(revision));
}
@ -384,9 +402,17 @@ impl store::FromHistory for Patch {
// TODO(cloudhead): Make sure we can deal with redacted revisions which are added
// to out of order, like in the `Merge` case.
if let Some(Redactable::Present(revision)) = self.revisions.get_mut(&revision) {
revision
.discussion
.apply([cob::Op::new(op.id, action, op.author, timestamp, op.clock)])?;
revision.discussion.apply(
[cob::Op::new(
op.id,
action,
op.author,
timestamp,
op.clock,
op.identity,
)],
repo,
)?;
} else {
return Err(ApplyError::Missing(revision));
}
@ -487,6 +513,7 @@ pub enum State {
Open,
Draft,
Archived,
Merged,
}
impl fmt::Display for State {
@ -495,6 +522,7 @@ impl fmt::Display for State {
Self::Archived => write!(f, "archived"),
Self::Draft => write!(f, "draft"),
Self::Open => write!(f, "open"),
Self::Merged => write!(f, "merged"),
}
}
}
@ -830,7 +858,7 @@ impl<'a, 'g> PatchMut<'a, 'g> {
operations(&mut tx)?;
let (ops, clock, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
self.patch.apply(ops)?;
self.patch.apply(ops, self.store.as_ref())?;
self.clock = clock;
Ok(commit)
@ -953,6 +981,7 @@ pub struct PatchCounts {
pub open: usize,
pub draft: usize,
pub archived: usize,
pub merged: usize,
}
pub struct Patches<'a> {
@ -1010,6 +1039,7 @@ impl<'a> Patches<'a> {
State::Draft => state.draft += 1,
State::Open => state.open += 1,
State::Archived => state.archived += 1,
State::Merged => state.merged += 1,
}
state
});
@ -1059,7 +1089,7 @@ impl<'a> Patches<'a> {
/// Get proposed patches.
pub fn proposed(
&self,
) -> Result<impl Iterator<Item = (PatchId, Patch, clock::Lamport)>, Error> {
) -> Result<impl Iterator<Item = (PatchId, Patch, clock::Lamport)> + 'a, Error> {
let all = self.all()?;
Ok(all
@ -1094,6 +1124,8 @@ mod test {
use crate::cob::test::Actor;
use crate::crypto::test::signer::MockSigner;
use crate::test;
use crate::test::arbitrary::gen;
use crate::test::storage::MockRepository;
#[derive(Clone)]
struct Changes<const N: usize> {
@ -1212,22 +1244,22 @@ mod test {
#[test]
fn prop_invariants() {
fn property(log: Changes<3>) -> TestResult {
fn property(repo: MockRepository, log: Changes<3>) -> TestResult {
let t = Patch::default();
let [p1, p2, p3] = log.permutations;
let mut t1 = t.clone();
if t1.apply(p1).is_err() {
if t1.apply(p1, &repo).is_err() {
return TestResult::discard();
}
let mut t2 = t.clone();
if t2.apply(p2).is_err() {
if t2.apply(p2, &repo).is_err() {
return TestResult::discard();
}
let mut t3 = t;
if t3.apply(p3).is_err() {
if t3.apply(p3, &repo).is_err() {
return TestResult::discard();
}
@ -1241,7 +1273,7 @@ mod test {
qcheck::QuickCheck::new()
.min_tests_passed(100)
.gen(qcheck::Gen::new(7))
.quickcheck(property as fn(Changes<3>) -> TestResult);
.quickcheck(property as fn(MockRepository, Changes<3>) -> TestResult);
}
#[test]
@ -1413,6 +1445,7 @@ mod test {
let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
let mut alice = Actor::<_, Action>::new(MockSigner::default());
let mut patch = Patch::default();
let repo = gen::<MockRepository>(1);
let a1 = alice.op(Action::Revision {
description: String::new(),
@ -1431,20 +1464,21 @@ mod test {
commit: oid,
});
patch.apply([a1]).unwrap();
patch.apply([a1], &repo).unwrap();
assert!(patch.revisions().next().is_some());
patch.apply([a2]).unwrap();
patch.apply([a2], &repo).unwrap();
assert!(patch.revisions().next().is_none());
patch.apply([a3]).unwrap_err();
patch.apply([a4]).unwrap_err();
patch.apply([a3], &repo).unwrap_err();
patch.apply([a4], &repo).unwrap_err();
}
#[test]
fn test_revision_redact_reinsert() {
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
let repo = gen::<MockRepository>(1);
let mut alice = Actor::<_, Action>::new(MockSigner::default());
let mut p1 = Patch::default();
let mut p2 = Patch::default();
@ -1456,8 +1490,9 @@ mod test {
});
let a2 = alice.op(Action::Redact { revision: a1.id() });
p1.apply([a1.clone(), a2.clone(), a1.clone()]).unwrap();
p2.apply([a1.clone(), a1, a2]).unwrap();
p1.apply([a1.clone(), a2.clone(), a1.clone()], &repo)
.unwrap();
p2.apply([a1.clone(), a1, a2], &repo).unwrap();
assert_eq!(p1, p2);
}
@ -1466,6 +1501,7 @@ mod test {
fn test_revision_merge_reinsert() {
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
let repo = gen::<MockRepository>(1);
let mut alice = Actor::<_, Action>::new(MockSigner::default());
let mut p1 = Patch::default();
let mut p2 = Patch::default();
@ -1480,8 +1516,9 @@ mod test {
commit: oid,
});
p1.apply([a1.clone(), a2.clone(), a1.clone()]).unwrap();
p2.apply([a1.clone(), a1, a2]).unwrap();
p1.apply([a1.clone(), a2.clone(), a1.clone()], &repo)
.unwrap();
p2.apply([a1.clone(), a1, a2], &repo).unwrap();
assert_eq!(p1, p2);
}

View File

@ -30,15 +30,21 @@ pub trait FromHistory: Sized + Default {
fn type_name() -> &'static TypeName;
/// Apply a list of operations to the state.
fn apply(&mut self, ops: impl IntoIterator<Item = Op<Self::Action>>)
-> Result<(), Self::Error>;
fn apply<R: ReadRepository>(
&mut self,
ops: impl IntoIterator<Item = Op<Self::Action>>,
repo: &R,
) -> Result<(), Self::Error>;
/// Create an object from a history.
fn from_history(history: &History) -> Result<(Self, Lamport), Error> {
fn from_history<R: ReadRepository>(
history: &History,
repo: &R,
) -> Result<(Self, Lamport), Error> {
let obj = history.traverse(Self::default(), |mut acc, entry| {
match Ops::try_from(entry) {
Ok(Ops(ops)) => {
if let Err(err) = acc.apply(ops) {
if let Err(err) = acc.apply(ops, repo) {
log::warn!("Error applying op to `{}` state: {err}", Self::type_name());
return ControlFlow::Break(acc);
}
@ -59,9 +65,12 @@ pub trait FromHistory: Sized + Default {
/// Create an object from individual operations.
/// Returns an error if any of the operations fails to apply.
fn from_ops(ops: impl IntoIterator<Item = Op<Self::Action>>) -> Result<Self, Self::Error> {
fn from_ops<R: ReadRepository>(
ops: impl IntoIterator<Item = Op<Self::Action>>,
repo: &R,
) -> Result<Self, Self::Error> {
let mut state = Self::default();
state.apply(ops)?;
state.apply(ops, repo)?;
Ok(state)
}
@ -168,7 +177,7 @@ where
contents,
},
)?;
let (object, clock) = T::from_history(cob.history())?;
let (object, clock) = T::from_history(cob.history(), self.repo)?;
self.repo.sign_refs(signer).map_err(Error::SignRefs)?;
@ -183,7 +192,7 @@ where
if cob.manifest().history_type != HISTORY_TYPE {
return Err(Error::HistoryType(cob.manifest().history_type.clone()));
}
let (obj, clock) = T::from_history(cob.history())?;
let (obj, clock) = T::from_history(cob.history(), self.repo)?;
Ok(Some((obj, clock)))
} else {
@ -194,11 +203,11 @@ where
/// Return all objects.
pub fn all(
&self,
) -> Result<impl Iterator<Item = Result<(ObjectId, T, Lamport), Error>>, Error> {
) -> Result<impl Iterator<Item = Result<(ObjectId, T, Lamport), Error>> + 'a, Error> {
let raw = cob::list(self.repo, T::type_name())?;
Ok(raw.into_iter().map(|o| {
let (obj, clock) = T::from_history(o.history())?;
let (obj, clock) = T::from_history(o.history(), self.repo)?;
Ok((*o.id(), obj, clock))
}))
}
@ -295,6 +304,7 @@ impl<T: FromHistory> Transaction<T> {
let author = self.actor;
let timestamp = object.history().timestamp().into();
let clock = self.clock.tick();
let identity = store.parent;
// The history clock should be in sync with the tx clock.
assert_eq!(object.history().clock(), self.clock.get());
@ -308,6 +318,7 @@ impl<T: FromHistory> Transaction<T> {
author,
clock,
timestamp,
identity,
})
.collect();

View File

@ -151,12 +151,14 @@ impl<G: Signer, A: Clone + Serialize> Actor<G, A> {
let author = *self.signer.public_key();
let clock = self.clock.tick();
let timestamp = clock::Physical::now();
let identity = arbitrary::oid();
let op = Op {
id,
action,
author,
clock,
timestamp,
identity,
};
self.ops.insert((self.clock, author), op.clone());

View File

@ -9,6 +9,7 @@ use thiserror::Error;
use crate::cob;
use crate::cob::common::{Reaction, Timestamp};
use crate::cob::{ActorId, EntryId, Op};
use crate::prelude::ReadRepository;
use crdt::clock::Lamport;
use crdt::{GMap, GSet, LWWSet, Max, Redactable, Semilattice};
@ -261,7 +262,11 @@ impl cob::store::FromHistory for Thread {
&*TYPENAME
}
fn apply(&mut self, ops: impl IntoIterator<Item = Op<Action>>) -> Result<(), OpError> {
fn apply<R: ReadRepository>(
&mut self,
ops: impl IntoIterator<Item = Op<Action>>,
_repo: &R,
) -> Result<(), OpError> {
for op in ops.into_iter() {
let id = op.id;
let author = op.author;
@ -331,6 +336,8 @@ mod tests {
use crate::cob::test;
use crate::crypto::test::signer::MockSigner;
use crate::crypto::Signer;
use crate::test::arbitrary::gen;
use crate::test::storage::MockRepository;
/// An object that can be used to create and sign changes.
pub struct Actor<G> {
@ -489,6 +496,7 @@ mod tests {
fn test_redact_comment() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, _) = radicle::test::setup::context(&tmp);
let repo = gen::<MockRepository>(1);
let mut alice = Actor::new(signer);
let mut thread = Thread::default();
@ -496,12 +504,12 @@ mod tests {
let a1 = alice.comment("Second comment", Some(a0.id()));
let a2 = alice.comment("Third comment", Some(a0.id()));
thread.apply([a0, a1.clone(), a2]).unwrap();
thread.apply([a0, a1.clone(), a2], &repo).unwrap();
assert_eq!(thread.comments().count(), 3);
// Redact the second comment.
let a3 = alice.redact(a1.id());
thread.apply([a3]).unwrap();
thread.apply([a3], &repo).unwrap();
let (_, comment0) = thread.comments().nth(0).unwrap();
let (_, comment1) = thread.comments().nth(1).unwrap();
@ -514,13 +522,15 @@ mod tests {
#[test]
fn test_edit_comment() {
let mut alice = Actor::<MockSigner>::default();
let repo = gen::<MockRepository>(1);
let c0 = alice.comment("Hello world!", None);
let c1 = alice.edit(c0.id(), "Goodbye world.");
let c2 = alice.edit(c0.id(), "Goodbye world!");
let mut t1 = Thread::default();
t1.apply([c0.clone(), c1.clone(), c2.clone()]).unwrap();
t1.apply([c0.clone(), c1.clone(), c2.clone()], &repo)
.unwrap();
let comment = t1.comment(&c0.id());
let edits = comment.unwrap().edits().collect::<Vec<_>>();
@ -531,7 +541,7 @@ mod tests {
assert_eq!(t1.comment(&c0.id()).unwrap().body(), "Goodbye world!");
let mut t2 = Thread::default();
t2.apply([c0, c2, c1]).unwrap(); // Apply in different order.
t2.apply([c0, c2, c1], &repo).unwrap(); // Apply in different order.
assert_eq!(t1, t2);
}
@ -610,6 +620,8 @@ mod tests {
#[test]
fn test_histories() {
let repo = gen::<MockRepository>(1);
let mut alice = Actor::<MockSigner>::default();
let mut bob = Actor::<MockSigner>::default();
let mut eve = Actor::<MockSigner>::default();
@ -628,15 +640,17 @@ mod tests {
a.merge(b);
a.merge(e);
let (expected, _) = Thread::from_history(&a).unwrap();
let (expected, _) = Thread::from_history(&a, &repo).unwrap();
for permutation in a.permutations(2) {
let actual = Thread::from_ops(permutation).unwrap();
let actual = Thread::from_ops(permutation, &repo).unwrap();
assert_eq!(actual, expected);
}
}
#[test]
fn test_duplicate_comments() {
let repo = gen::<MockRepository>(1);
let mut alice = Actor::<MockSigner>::default();
let mut bob = Actor::<MockSigner>::default();
@ -649,7 +663,7 @@ mod tests {
b.append(&b0);
a.merge(b);
let (thread, _) = Thread::from_history(&a).unwrap();
let (thread, _) = Thread::from_history(&a, &repo).unwrap();
assert_eq!(thread.comments().count(), 2);
@ -662,6 +676,8 @@ mod tests {
#[test]
fn test_duplicate_comments_same_author() {
let repo = gen::<MockRepository>(1);
let mut alice = Actor::<MockSigner>::default();
let a0 = alice.comment("Hello World!", None);
@ -681,7 +697,7 @@ mod tests {
h3.merge(h1);
h3.merge(h2);
let (thread, _) = Thread::from_history(&h3).unwrap();
let (thread, _) = Thread::from_history(&h3, &repo).unwrap();
// The three comments, distinct yet identical in terms of content, are preserved.
assert_eq!(thread.comments().count(), 3);
@ -700,6 +716,8 @@ mod tests {
#[test]
fn test_comment_edit_reinsert() {
let repo = gen::<MockRepository>(1);
let mut alice = Actor::<MockSigner>::default();
let mut t1 = Thread::default();
let mut t2 = Thread::default();
@ -707,30 +725,31 @@ mod tests {
let a1 = alice.comment("Hello.", None);
let a2 = alice.edit(a1.id(), "Hello World.");
t1.apply([a1.clone(), a2.clone(), a1.clone()]).unwrap();
t2.apply([a1.clone(), a1, a2]).unwrap();
t1.apply([a1.clone(), a2.clone(), a1.clone()], &repo)
.unwrap();
t2.apply([a1.clone(), a1, a2], &repo).unwrap();
assert_eq!(t1, t2);
}
#[test]
fn prop_invariants() {
fn property(log: Changes<3>) -> TestResult {
fn property(repo: MockRepository, log: Changes<3>) -> TestResult {
let t = Thread::default();
let [p1, p2, p3] = log.permutations;
let mut t1 = t.clone();
if t1.apply(p1).is_err() {
if t1.apply(p1, &repo).is_err() {
return TestResult::discard();
}
let mut t2 = t.clone();
if t2.apply(p2).is_err() {
if t2.apply(p2, &repo).is_err() {
return TestResult::discard();
}
let mut t3 = t;
if t3.apply(p3).is_err() {
if t3.apply(p3, &repo).is_err() {
return TestResult::discard();
}
@ -744,6 +763,6 @@ mod tests {
.min_tests_passed(100)
.max_tests(10000)
.gen(qcheck::Gen::new(7))
.quickcheck(property as fn(Changes<3>) -> TestResult);
.quickcheck(property as fn(MockRepository, Changes<3>) -> TestResult);
}
}

View File

@ -127,6 +127,14 @@ pub struct DocAt {
pub sigs: HashMap<PublicKey, Signature>,
}
impl Deref for DocAt {
type Target = Doc<Verified>;
fn deref(&self) -> &Self::Target {
&self.doc
}
}
/// An identity document.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]

View File

@ -369,7 +369,15 @@ pub trait ReadRepository {
}
/// Get the repository's identity document.
fn identity_doc(&self) -> Result<(Oid, identity::Doc<Unverified>), IdentityError>;
fn identity_doc(&self) -> Result<(Oid, identity::Doc<Unverified>), IdentityError> {
let head = self.identity_head()?;
let doc = self.identity_doc_at(head)?;
Ok((head, doc))
}
/// Get the repository's identity document at a specific commit.
fn identity_doc_at(&self, head: Oid) -> Result<identity::Doc<Unverified>, DocError>;
}
/// Allows read-write access to a repository.

View File

@ -11,6 +11,7 @@ use once_cell::sync::Lazy;
use crate::git;
use crate::identity;
use crate::identity::doc::DocError;
use crate::identity::{Doc, Id};
use crate::identity::{Identity, IdentityError, Project};
use crate::storage::refs;
@ -496,12 +497,8 @@ impl ReadRepository for Repository {
Ok(Remotes::from_iter(remotes))
}
fn identity_doc(&self) -> Result<(Oid, identity::Doc<Unverified>), IdentityError> {
let head = self.identity_head()?;
Doc::<Unverified>::load_at(head, self)
.map(|(doc, _)| (head, doc))
.map_err(IdentityError::from)
fn identity_doc_at(&self, head: Oid) -> Result<identity::Doc<Unverified>, DocError> {
Doc::<Unverified>::load_at(head, self).map(|(doc, _)| doc)
}
fn head(&self) -> Result<(Qualified, Oid), IdentityError> {

View File

@ -18,7 +18,7 @@ use crate::identity::{
use crate::node::Address;
use crate::storage;
use crate::storage::refs::{Refs, SignedRefs};
use crate::test::storage::MockStorage;
use crate::test::storage::{MockRepository, MockStorage};
pub fn oid() -> storage::Oid {
let oid_bytes: [u8; 20] = gen(1);
@ -180,6 +180,15 @@ impl Arbitrary for MockStorage {
}
}
impl Arbitrary for MockRepository {
fn arbitrary(g: &mut qcheck::Gen) -> Self {
let rid = Id::arbitrary(g);
let doc = Doc::<Verified>::arbitrary(g);
Self::new(rid, doc)
}
}
impl Arbitrary for storage::Remote<crypto::Verified> {
fn arbitrary(g: &mut qcheck::Gen) -> Self {
let refs = Refs::arbitrary(g);

View File

@ -6,7 +6,7 @@ use git_ref_format as fmt;
use radicle_git_ext as git_ext;
use crate::crypto::{Signer, Verified};
use crate::identity::doc::{Doc, Id};
use crate::identity::doc::{Doc, DocError, Id};
use crate::identity::IdentityError;
use crate::node::NodeId;
@ -113,6 +113,16 @@ pub struct MockRepository {
remotes: HashMap<NodeId, refs::SignedRefs<Verified>>,
}
impl MockRepository {
pub fn new(id: Id, doc: Doc<Verified>) -> Self {
Self {
id,
doc,
remotes: HashMap::default(),
}
}
}
impl ReadRepository for MockRepository {
fn id(&self) -> Id {
self.id
@ -195,6 +205,13 @@ impl ReadRepository for MockRepository {
Ok((git2::Oid::zero().into(), self.doc.clone().unverified()))
}
fn identity_doc_at(
&self,
_head: Oid,
) -> Result<crate::identity::Doc<crate::crypto::Unverified>, DocError> {
Ok(self.doc.clone().unverified())
}
fn identity_head(&self) -> Result<Oid, IdentityError> {
todo!()
}