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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -369,7 +369,15 @@ pub trait ReadRepository {
} }
/// Get the repository's identity document. /// 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. /// Allows read-write access to a repository.

View File

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

View File

@ -18,7 +18,7 @@ use crate::identity::{
use crate::node::Address; use crate::node::Address;
use crate::storage; use crate::storage;
use crate::storage::refs::{Refs, SignedRefs}; use crate::storage::refs::{Refs, SignedRefs};
use crate::test::storage::MockStorage; use crate::test::storage::{MockRepository, MockStorage};
pub fn oid() -> storage::Oid { pub fn oid() -> storage::Oid {
let oid_bytes: [u8; 20] = gen(1); 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> { impl Arbitrary for storage::Remote<crypto::Verified> {
fn arbitrary(g: &mut qcheck::Gen) -> Self { fn arbitrary(g: &mut qcheck::Gen) -> Self {
let refs = Refs::arbitrary(g); 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 radicle_git_ext as git_ext;
use crate::crypto::{Signer, Verified}; use crate::crypto::{Signer, Verified};
use crate::identity::doc::{Doc, Id}; use crate::identity::doc::{Doc, DocError, Id};
use crate::identity::IdentityError; use crate::identity::IdentityError;
use crate::node::NodeId; use crate::node::NodeId;
@ -113,6 +113,16 @@ pub struct MockRepository {
remotes: HashMap<NodeId, refs::SignedRefs<Verified>>, 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 { impl ReadRepository for MockRepository {
fn id(&self) -> Id { fn id(&self) -> Id {
self.id self.id
@ -195,6 +205,13 @@ impl ReadRepository for MockRepository {
Ok((git2::Oid::zero().into(), self.doc.clone().unverified())) 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> { fn identity_head(&self) -> Result<Oid, IdentityError> {
todo!() todo!()
} }