cob: Add support for media embeds in COBs
This change allows for files (blobs) to be embedded into COB entries. This allows for things like image attachments in issue comments for example. For now, we only enable this in the `issue` COB. The way it works is that relevant COB actions carry metadata about which files are attached to them; and we store those files as blobs inside the COB entry's *tree* object, under an `embeds/` folder. The `Embed<T>` type is used for the above, and either carries actual content, or carries a content-id. Retrieving the actual content is as simple as asking the repository for a blob with that content-id. As a possible future extension, MIME types could be stored alongside the files in a "metadata" file. This could help clients display the content appropriately.
This commit is contained in:
parent
f282e09dce
commit
534503d002
|
|
@ -1924,6 +1924,7 @@ dependencies = [
|
||||||
"git2",
|
"git2",
|
||||||
"log",
|
"log",
|
||||||
"nonempty 0.8.1",
|
"nonempty 0.8.1",
|
||||||
|
"once_cell",
|
||||||
"qcheck",
|
"qcheck",
|
||||||
"qcheck-macros",
|
"qcheck-macros",
|
||||||
"radicle-crypto",
|
"radicle-crypto",
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ fn comment(
|
||||||
let (comment_id, _) = issue.comments().next().expect("root comment always exists");
|
let (comment_id, _) = issue.comments().next().expect("root comment always exists");
|
||||||
*comment_id
|
*comment_id
|
||||||
});
|
});
|
||||||
let comment_id = issue.comment(message, comment_id, &signer)?;
|
let comment_id = issue.comment(message, comment_id, vec![], &signer)?;
|
||||||
|
|
||||||
term::print(comment_id);
|
term::print(comment_id);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
description: Some(description),
|
description: Some(description),
|
||||||
labels,
|
labels,
|
||||||
} => {
|
} => {
|
||||||
let issue = issues.create(title, description, labels.as_slice(), &[], &signer)?;
|
let issue = issues.create(title, description, labels.as_slice(), &[], [], &signer)?;
|
||||||
if !options.quiet {
|
if !options.quiet {
|
||||||
show_issue(&issue, issue.id())?;
|
show_issue(&issue, issue.id())?;
|
||||||
}
|
}
|
||||||
|
|
@ -569,6 +569,7 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
|
||||||
description.trim(),
|
description.trim(),
|
||||||
meta.labels.as_slice(),
|
meta.labels.as_slice(),
|
||||||
meta.assignees.as_slice(),
|
meta.assignees.as_slice(),
|
||||||
|
[],
|
||||||
signer,
|
signer,
|
||||||
)?;
|
)?;
|
||||||
if !options.quiet {
|
if !options.quiet {
|
||||||
|
|
@ -598,7 +599,7 @@ fn edit<R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
|
||||||
tx.edit(t)?;
|
tx.edit(t)?;
|
||||||
}
|
}
|
||||||
if let Some(d) = description {
|
if let Some(d) = description {
|
||||||
tx.edit_comment(desc_id, d)?;
|
tx.edit_comment(desc_id, d, vec![])?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -621,7 +622,7 @@ fn edit<R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
|
||||||
|
|
||||||
issue.transaction("Edit", signer, |tx| {
|
issue.transaction("Edit", signer, |tx| {
|
||||||
tx.edit(edited.title)?;
|
tx.edit(edited.title)?;
|
||||||
tx.edit_comment(desc_id, description)?;
|
tx.edit_comment(desc_id, description, vec![])?;
|
||||||
tx.label(edited.labels)?;
|
tx.label(edited.labels)?;
|
||||||
tx.assign(edited.assignees)?;
|
tx.assign(edited.assignees)?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -794,6 +794,7 @@ fn test_cob_replication() {
|
||||||
"I don't know what it is",
|
"I don't know what it is",
|
||||||
&[],
|
&[],
|
||||||
&[],
|
&[],
|
||||||
|
[],
|
||||||
&bob.signer,
|
&bob.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -843,6 +844,7 @@ fn test_cob_deletion() {
|
||||||
"I don't know what it is",
|
"I don't know what it is",
|
||||||
&[],
|
&[],
|
||||||
&[],
|
&[],
|
||||||
|
[],
|
||||||
&alice.signer,
|
&alice.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ keywords = ["radicle", "cob", "cobs"]
|
||||||
fastrand = { version = "1.9.0" }
|
fastrand = { version = "1.9.0" }
|
||||||
log = { version = "0.4.17" }
|
log = { version = "0.4.17" }
|
||||||
nonempty = { version = "0.8.1", features = ["serialize"] }
|
nonempty = { version = "0.8.1", features = ["serialize"] }
|
||||||
|
once_cell = { version = "1.13" }
|
||||||
radicle-git-ext = { version = "0.6.0", features = ["serde"] }
|
radicle-git-ext = { version = "0.6.0", features = ["serde"] }
|
||||||
serde_json = { version = "1.0" }
|
serde_json = { version = "1.0" }
|
||||||
thiserror = { version = "1.0" }
|
thiserror = { version = "1.0" }
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::convert::TryFrom;
|
use std::convert::TryFrom;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use git_ext::author::Author;
|
use git_ext::author::Author;
|
||||||
use git_ext::commit::{headers::Headers, Commit};
|
use git_ext::commit::{headers::Headers, Commit};
|
||||||
use git_ext::Oid;
|
use git_ext::Oid;
|
||||||
use nonempty::NonEmpty;
|
use nonempty::NonEmpty;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
use radicle_git_ext::commit::trailers::OwnedTrailer;
|
use radicle_git_ext::commit::trailers::OwnedTrailer;
|
||||||
|
|
||||||
use crate::change::store::Version;
|
use crate::change::store::Version;
|
||||||
|
|
@ -16,10 +18,13 @@ use crate::{
|
||||||
change::{self, store, Change},
|
change::{self, store, Change},
|
||||||
history::entry,
|
history::entry,
|
||||||
signatures::{ExtendedSignature, Signatures},
|
signatures::{ExtendedSignature, Signatures},
|
||||||
trailers,
|
trailers, Embed,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MANIFEST_BLOB_NAME: &str = "manifest";
|
/// Name of the COB manifest file.
|
||||||
|
pub const MANIFEST_BLOB_NAME: &str = "manifest";
|
||||||
|
/// Path under which COB embeds are kept.
|
||||||
|
pub static EMBEDS_PATH: Lazy<PathBuf> = Lazy::new(|| PathBuf::from("embeds"));
|
||||||
|
|
||||||
pub mod error {
|
pub mod error {
|
||||||
use std::str::Utf8Error;
|
use std::str::Utf8Error;
|
||||||
|
|
@ -102,10 +107,11 @@ impl change::Storage for git2::Repository {
|
||||||
type_name,
|
type_name,
|
||||||
tips,
|
tips,
|
||||||
message,
|
message,
|
||||||
|
embeds,
|
||||||
contents,
|
contents,
|
||||||
} = spec;
|
} = spec;
|
||||||
let manifest = store::Manifest::new(type_name, Version::default());
|
let manifest = store::Manifest::new(type_name, Version::default());
|
||||||
let revision = write_manifest(self, &manifest, &contents)?;
|
let revision = write_manifest(self, &manifest, embeds, &contents)?;
|
||||||
let tree = self.find_tree(revision)?;
|
let tree = self.find_tree(revision)?;
|
||||||
let signature = {
|
let signature = {
|
||||||
let sig = signer.sign(revision.as_bytes());
|
let sig = signer.sign(revision.as_bytes());
|
||||||
|
|
@ -281,6 +287,8 @@ where
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
let (author, timestamp) = if let Ok(s) = std::env::var(crate::git::RAD_COMMIT_TIME) {
|
let (author, timestamp) = if let Ok(s) = std::env::var(crate::git::RAD_COMMIT_TIME) {
|
||||||
|
// SAFETY: It's ok to panic here, since this is only enabled in debug mode.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
let timestamp = s.trim().parse::<i64>().unwrap();
|
let timestamp = s.trim().parse::<i64>().unwrap();
|
||||||
let author = Author {
|
let author = Author {
|
||||||
time: git_ext::author::Time::new(timestamp, 0),
|
time: git_ext::author::Time::new(timestamp, 0),
|
||||||
|
|
@ -308,24 +316,47 @@ where
|
||||||
fn write_manifest(
|
fn write_manifest(
|
||||||
repo: &git2::Repository,
|
repo: &git2::Repository,
|
||||||
manifest: &store::Manifest,
|
manifest: &store::Manifest,
|
||||||
|
embeds: Vec<Embed>,
|
||||||
contents: &NonEmpty<Vec<u8>>,
|
contents: &NonEmpty<Vec<u8>>,
|
||||||
) -> Result<git2::Oid, git2::Error> {
|
) -> Result<git2::Oid, git2::Error> {
|
||||||
let mut tb = repo.treebuilder(None)?;
|
let mut root = repo.treebuilder(None)?;
|
||||||
|
|
||||||
|
// Insert manifest file into tree.
|
||||||
|
{
|
||||||
// SAFETY: we're serializing to an in memory buffer so the only source of
|
// SAFETY: we're serializing to an in memory buffer so the only source of
|
||||||
// errors here is a programming error, which we can't recover from
|
// errors here is a programming error, which we can't recover from.
|
||||||
let serialized_manifest = serde_json::to_vec(manifest).unwrap();
|
#[allow(clippy::unwrap_used)]
|
||||||
let manifest_oid = repo.blob(&serialized_manifest)?;
|
let manifest = serde_json::to_vec(manifest).unwrap();
|
||||||
tb.insert(
|
let manifest_oid = repo.blob(&manifest)?;
|
||||||
|
|
||||||
|
root.insert(
|
||||||
MANIFEST_BLOB_NAME,
|
MANIFEST_BLOB_NAME,
|
||||||
manifest_oid,
|
manifest_oid,
|
||||||
git2::FileMode::Blob.into(),
|
git2::FileMode::Blob.into(),
|
||||||
)?;
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert each COB entry.
|
||||||
for (ix, op) in contents.iter().enumerate() {
|
for (ix, op) in contents.iter().enumerate() {
|
||||||
let oid = repo.blob(op.as_ref())?;
|
let oid = repo.blob(op.as_ref())?;
|
||||||
tb.insert(&ix.to_string(), oid, git2::FileMode::Blob.into())?;
|
root.insert(&ix.to_string(), oid, git2::FileMode::Blob.into())?;
|
||||||
}
|
}
|
||||||
let tree_oid = tb.write()?;
|
|
||||||
|
|
||||||
Ok(tree_oid)
|
// Insert each embed in a tree at `/embeds`.
|
||||||
|
if !embeds.is_empty() {
|
||||||
|
let mut embeds_tree = repo.treebuilder(None)?;
|
||||||
|
|
||||||
|
for embed in embeds {
|
||||||
|
let oid = repo.blob(&embed.content)?;
|
||||||
|
let path = PathBuf::from(embed.name);
|
||||||
|
|
||||||
|
embeds_tree.insert(path, oid, git2::FileMode::Blob.into())?;
|
||||||
|
}
|
||||||
|
let oid = embeds_tree.write()?;
|
||||||
|
|
||||||
|
root.insert(&*EMBEDS_PATH, oid, git2::FileMode::Tree.into())?;
|
||||||
|
}
|
||||||
|
let oid = root.write()?;
|
||||||
|
|
||||||
|
Ok(oid)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ pub struct Template<Id> {
|
||||||
pub type_name: TypeName,
|
pub type_name: TypeName,
|
||||||
pub tips: Vec<Id>,
|
pub tips: Vec<Id>,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
pub embeds: Vec<Embed>,
|
||||||
pub contents: NonEmpty<Vec<u8>>,
|
pub contents: NonEmpty<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,3 +180,38 @@ impl Version {
|
||||||
NonZeroUsize::new(version).map(Self)
|
NonZeroUsize::new(version).map(Self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Embedded object.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Embed<T = Vec<u8>> {
|
||||||
|
/// File name.
|
||||||
|
pub name: String,
|
||||||
|
/// File content or content hash.
|
||||||
|
pub content: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Embed<Vec<u8>> {
|
||||||
|
/// Get the object id of the embedded content.
|
||||||
|
pub fn oid(&self) -> Oid {
|
||||||
|
// SAFETY: This should not fail since we are using a valid object type.
|
||||||
|
git2::Oid::hash_object(git2::ObjectType::Blob, &self.content)
|
||||||
|
.expect("Embed::oid: invalid object")
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return am embed where the content is replaced by a content hash.
|
||||||
|
pub fn hashed<T: From<Oid>>(&self) -> Embed<T> {
|
||||||
|
Embed {
|
||||||
|
name: self.name.clone(),
|
||||||
|
content: T::from(self.oid()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Embed<Oid> {
|
||||||
|
/// Get the object id of the embedded content.
|
||||||
|
pub fn oid(&self) -> Oid {
|
||||||
|
self.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright © 2021 The Radicle Link Contributors
|
// Copyright © 2021 The Radicle Link Contributors
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
use std::ops::ControlFlow;
|
use std::ops::ControlFlow;
|
||||||
use std::{collections::BTreeSet, convert::TryInto};
|
|
||||||
|
|
||||||
use git_ext::Oid;
|
use git_ext::Oid;
|
||||||
use radicle_dag::Dag;
|
use radicle_dag::Dag;
|
||||||
|
|
@ -135,8 +135,8 @@ impl ChangeGraph {
|
||||||
self.graph.tips().map(|(_, change)| *change.id()).collect()
|
self.graph.tips().map(|(_, change)| *change.id()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn number_of_nodes(&self) -> u64 {
|
pub(crate) fn number_of_nodes(&self) -> usize {
|
||||||
self.graph.len().try_into().unwrap()
|
self.graph.len()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Copyright © 2021 The Radicle Link Contributors
|
// Copyright © 2021 The Radicle Link Contributors
|
||||||
|
|
||||||
|
#![warn(clippy::unwrap_used)]
|
||||||
//! # Collaborative Objects
|
//! # Collaborative Objects
|
||||||
//!
|
//!
|
||||||
//! Collaborative objects are graphs of CRDTs. The current CRDTs that
|
//! Collaborative objects are graphs of CRDTs. The current CRDTs that
|
||||||
|
|
@ -80,7 +81,7 @@ mod change_graph;
|
||||||
mod trailers;
|
mod trailers;
|
||||||
|
|
||||||
pub mod change;
|
pub mod change;
|
||||||
pub use change::store::{Manifest, Version};
|
pub use change::store::{Embed, Manifest, Version};
|
||||||
pub use change::Change;
|
pub use change::Change;
|
||||||
|
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
|
@ -98,9 +99,11 @@ pub use object::{
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
mod test;
|
mod test;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
/// The `Store` is an aggregation of the different types of storage
|
/// The `Store` is an aggregation of the different types of storage
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use nonempty::NonEmpty;
|
use nonempty::NonEmpty;
|
||||||
|
|
||||||
|
use crate::Embed;
|
||||||
use crate::Store;
|
use crate::Store;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -14,17 +15,20 @@ pub struct Create {
|
||||||
pub type_name: TypeName,
|
pub type_name: TypeName,
|
||||||
/// The message to add when creating this object.
|
/// The message to add when creating this object.
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
/// Embedded content.
|
||||||
|
pub embeds: Vec<Embed>,
|
||||||
/// COB version.
|
/// COB version.
|
||||||
pub version: Version,
|
pub version: Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Create {
|
impl Create {
|
||||||
fn template(&self) -> change::Template<git_ext::Oid> {
|
fn template(self) -> change::Template<git_ext::Oid> {
|
||||||
change::Template {
|
change::Template {
|
||||||
type_name: self.type_name.clone(),
|
type_name: self.type_name,
|
||||||
tips: Vec::new(),
|
tips: Vec::new(),
|
||||||
message: self.message.clone(),
|
message: self.message,
|
||||||
contents: self.contents.clone(),
|
embeds: self.embeds,
|
||||||
|
contents: self.contents,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -59,14 +63,15 @@ where
|
||||||
S: Store<I>,
|
S: Store<I>,
|
||||||
G: crypto::Signer,
|
G: crypto::Signer,
|
||||||
{
|
{
|
||||||
let Create { type_name, .. } = &args;
|
let type_name = args.type_name.clone();
|
||||||
|
let version = args.version;
|
||||||
let init_change = storage
|
let init_change = storage
|
||||||
.store(resource, parents, signer, args.template())
|
.store(resource, parents, signer, args.template())
|
||||||
.map_err(error::Create::from)?;
|
.map_err(error::Create::from)?;
|
||||||
let object_id = init_change.id().into();
|
let object_id = init_change.id().into();
|
||||||
|
|
||||||
storage
|
storage
|
||||||
.update(identifier, type_name, &object_id, &init_change)
|
.update(identifier, &type_name, &object_id, &init_change)
|
||||||
.map_err(|err| error::Create::Refs { err: Box::new(err) })?;
|
.map_err(|err| error::Create::Refs { err: Box::new(err) })?;
|
||||||
|
|
||||||
let history = History::new_from_root(
|
let history = History::new_from_root(
|
||||||
|
|
@ -79,7 +84,7 @@ where
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(CollaborativeObject {
|
Ok(CollaborativeObject {
|
||||||
manifest: Manifest::new(args.type_name, args.version),
|
manifest: Manifest::new(type_name, version),
|
||||||
history,
|
history,
|
||||||
id: object_id,
|
id: object_id,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ pub struct ChangeGraphInfo {
|
||||||
/// The ID of the object
|
/// The ID of the object
|
||||||
pub object_id: ObjectId,
|
pub object_id: ObjectId,
|
||||||
/// The number of nodes in the change graph of the object
|
/// The number of nodes in the change graph of the object
|
||||||
pub number_of_nodes: u64,
|
pub number_of_nodes: usize,
|
||||||
/// The "tips" of the change graph, i.e the object IDs pointed to by
|
/// The "tips" of the change graph, i.e the object IDs pointed to by
|
||||||
/// references to the object
|
/// references to the object
|
||||||
pub tips: BTreeSet<Oid>,
|
pub tips: BTreeSet<Oid>,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ use git_ext::Oid;
|
||||||
use nonempty::NonEmpty;
|
use nonempty::NonEmpty;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, ObjectId, Store,
|
change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, Embed, ObjectId,
|
||||||
TypeName,
|
Store, TypeName,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::error;
|
use super::error;
|
||||||
|
|
@ -31,6 +31,8 @@ pub struct Update {
|
||||||
pub type_name: TypeName,
|
pub type_name: TypeName,
|
||||||
/// The message to add when updating this object.
|
/// The message to add when updating this object.
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
/// Embedded files.
|
||||||
|
pub embeds: Vec<Embed>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update an existing [`CollaborativeObject`].
|
/// Update an existing [`CollaborativeObject`].
|
||||||
|
|
@ -67,6 +69,7 @@ where
|
||||||
let Update {
|
let Update {
|
||||||
type_name: ref typename,
|
type_name: ref typename,
|
||||||
object_id,
|
object_id,
|
||||||
|
embeds,
|
||||||
changes,
|
changes,
|
||||||
message,
|
message,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
@ -85,6 +88,7 @@ where
|
||||||
signer,
|
signer,
|
||||||
change::Template {
|
change::Template {
|
||||||
tips: object.tips().iter().cloned().collect(),
|
tips: object.tips().iter().cloned().collect(),
|
||||||
|
embeds,
|
||||||
contents: changes,
|
contents: changes,
|
||||||
type_name: typename.clone(),
|
type_name: typename.clone(),
|
||||||
message,
|
message,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ fn roundtrip() {
|
||||||
contents: nonempty!(Vec::new()),
|
contents: nonempty!(Vec::new()),
|
||||||
type_name: typename.clone(),
|
type_name: typename.clone(),
|
||||||
message: "creating xyz.rad.issue".to_string(),
|
message: "creating xyz.rad.issue".to_string(),
|
||||||
|
embeds: vec![],
|
||||||
version: Version::default(),
|
version: Version::default(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -67,6 +68,7 @@ fn list_cobs() {
|
||||||
contents: nonempty!(b"issue 1".to_vec()),
|
contents: nonempty!(b"issue 1".to_vec()),
|
||||||
type_name: typename.clone(),
|
type_name: typename.clone(),
|
||||||
message: "creating xyz.rad.issue".to_string(),
|
message: "creating xyz.rad.issue".to_string(),
|
||||||
|
embeds: vec![],
|
||||||
version: Version::default(),
|
version: Version::default(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -82,6 +84,7 @@ fn list_cobs() {
|
||||||
contents: nonempty!(b"issue 2".to_vec()),
|
contents: nonempty!(b"issue 2".to_vec()),
|
||||||
type_name: typename.clone(),
|
type_name: typename.clone(),
|
||||||
message: "commenting xyz.rad.issue".to_string(),
|
message: "commenting xyz.rad.issue".to_string(),
|
||||||
|
embeds: vec![],
|
||||||
version: Version::default(),
|
version: Version::default(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -117,6 +120,7 @@ fn update_cob() {
|
||||||
contents: nonempty!(Vec::new()),
|
contents: nonempty!(Vec::new()),
|
||||||
type_name: typename.clone(),
|
type_name: typename.clone(),
|
||||||
message: "creating xyz.rad.issue".to_string(),
|
message: "creating xyz.rad.issue".to_string(),
|
||||||
|
embeds: vec![],
|
||||||
version: Version::default(),
|
version: Version::default(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -136,6 +140,7 @@ fn update_cob() {
|
||||||
changes: nonempty!(b"issue 1".to_vec()),
|
changes: nonempty!(b"issue 1".to_vec()),
|
||||||
object_id: *cob.id(),
|
object_id: *cob.id(),
|
||||||
type_name: typename.clone(),
|
type_name: typename.clone(),
|
||||||
|
embeds: vec![],
|
||||||
message: "commenting xyz.rad.issue".to_string(),
|
message: "commenting xyz.rad.issue".to_string(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -176,6 +181,7 @@ fn traverse_cobs() {
|
||||||
contents: nonempty!(b"issue 1".to_vec()),
|
contents: nonempty!(b"issue 1".to_vec()),
|
||||||
type_name: typename.clone(),
|
type_name: typename.clone(),
|
||||||
message: "creating xyz.rad.issue".to_string(),
|
message: "creating xyz.rad.issue".to_string(),
|
||||||
|
embeds: vec![],
|
||||||
version: Version::default(),
|
version: Version::default(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -199,6 +205,7 @@ fn traverse_cobs() {
|
||||||
changes: nonempty!(b"issue 2".to_vec()),
|
changes: nonempty!(b"issue 2".to_vec()),
|
||||||
object_id: *cob.id(),
|
object_id: *cob.id(),
|
||||||
type_name: typename,
|
type_name: typename,
|
||||||
|
embeds: vec![],
|
||||||
message: "commenting on xyz.rad.issue".to_string(),
|
message: "commenting on xyz.rad.issue".to_string(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ impl From<git2::Oid> for ResourceCommitTrailer {
|
||||||
impl From<ResourceCommitTrailer> for Trailer<'_> {
|
impl From<ResourceCommitTrailer> for Trailer<'_> {
|
||||||
fn from(containing: ResourceCommitTrailer) -> Self {
|
fn from(containing: ResourceCommitTrailer) -> Self {
|
||||||
Trailer {
|
Trailer {
|
||||||
|
// SAFETY: "Rad-Resource" is a valid `Token`.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
token: Token::try_from("Rad-Resource").unwrap(),
|
token: Token::try_from("Rad-Resource").unwrap(),
|
||||||
value: containing.0.to_string().into(),
|
value: containing.0.to_string().into(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -507,6 +507,7 @@ async fn issue_create_handler(
|
||||||
issue.description,
|
issue.description,
|
||||||
&issue.labels,
|
&issue.labels,
|
||||||
&issue.assignees,
|
&issue.assignees,
|
||||||
|
[],
|
||||||
&signer,
|
&signer,
|
||||||
)
|
)
|
||||||
.map_err(Error::from)?;
|
.map_err(Error::from)?;
|
||||||
|
|
@ -546,9 +547,9 @@ async fn issue_update_handler(
|
||||||
issue::Action::Edit { title } => {
|
issue::Action::Edit { title } => {
|
||||||
issue.edit(title, &signer)?;
|
issue.edit(title, &signer)?;
|
||||||
}
|
}
|
||||||
issue::Action::Comment { body, reply_to } => {
|
issue::Action::Comment { body, reply_to, .. } => {
|
||||||
if let Some(to) = reply_to {
|
if let Some(to) = reply_to {
|
||||||
issue.comment(body, to, &signer)?;
|
issue.comment(body, to, [], &signer)?;
|
||||||
} else {
|
} else {
|
||||||
return Err(Error::BadRequest("`replyTo` missing".to_owned()));
|
return Err(Error::BadRequest("`replyTo` missing".to_owned()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
|
||||||
"Change 'hello world' to 'hello everyone'".to_string(),
|
"Change 'hello world' to 'hello everyone'".to_string(),
|
||||||
&[],
|
&[],
|
||||||
&[],
|
&[],
|
||||||
|
[],
|
||||||
signer,
|
signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -315,7 +315,7 @@ impl<G: Signer + cyphernet::Ecdh> NodeHandle<G> {
|
||||||
let repo = self.storage.repository(rid).unwrap();
|
let repo = self.storage.repository(rid).unwrap();
|
||||||
let mut issues = issue::Issues::open(&repo).unwrap();
|
let mut issues = issue::Issues::open(&repo).unwrap();
|
||||||
*issues
|
*issues
|
||||||
.create(title, desc, &[], &[], &self.signer)
|
.create(title, desc, &[], &[], [], &self.signer)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.id()
|
.id()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pub mod test;
|
||||||
|
|
||||||
pub use cob::{
|
pub use cob::{
|
||||||
change, history::EntryId, object, object::collaboration::error, CollaborativeObject, Contents,
|
change, history::EntryId, object, object::collaboration::error, CollaborativeObject, Contents,
|
||||||
Create, Entry, History, Manifest, ObjectId, Store, TypeName, Update, Updated, Version,
|
Create, Embed, Entry, History, Manifest, ObjectId, Store, TypeName, Update, Updated, Version,
|
||||||
};
|
};
|
||||||
pub use cob::{create, get, list, remove, update};
|
pub use cob::{create, get, list, remove, update};
|
||||||
pub use common::*;
|
pub use common::*;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use std::str::FromStr;
|
||||||
use localtime::LocalTime;
|
use localtime::LocalTime;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::git_ext::Oid;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
/// Timestamp used for COB operations.
|
/// Timestamp used for COB operations.
|
||||||
|
|
@ -226,6 +227,57 @@ impl<'a> Deserialize<'a> for Color {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A URI.
|
||||||
|
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct Uri(String);
|
||||||
|
|
||||||
|
impl Uri {
|
||||||
|
/// Get a string reference to the URI.
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
self.0.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Oid> for Uri {
|
||||||
|
fn from(oid: Oid) -> Self {
|
||||||
|
Uri(format!("git:{oid}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&Uri> for Oid {
|
||||||
|
type Error = Uri;
|
||||||
|
|
||||||
|
fn try_from(value: &Uri) -> Result<Self, Self::Error> {
|
||||||
|
if let Some(oid) = value.as_str().strip_prefix("git:") {
|
||||||
|
let oid = oid.parse().map_err(|_| value.clone())?;
|
||||||
|
|
||||||
|
return Ok(oid);
|
||||||
|
}
|
||||||
|
Err(value.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Uri {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for Uri {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
if !s.chars().all(|c| c.is_ascii()) {
|
||||||
|
return Err(s.to_owned());
|
||||||
|
}
|
||||||
|
if !s.contains(':') {
|
||||||
|
return Err(s.to_owned());
|
||||||
|
}
|
||||||
|
Ok(Self(s.to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -320,7 +320,7 @@ impl store::FromHistory for Proposal {
|
||||||
type Error = ApplyError;
|
type Error = ApplyError;
|
||||||
|
|
||||||
fn type_name() -> &'static TypeName {
|
fn type_name() -> &'static TypeName {
|
||||||
&*TYPENAME
|
&TYPENAME
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), Self::Error> {
|
fn validate(&self) -> Result<(), Self::Error> {
|
||||||
|
|
@ -390,6 +390,7 @@ impl store::FromHistory for Proposal {
|
||||||
body,
|
body,
|
||||||
reply_to,
|
reply_to,
|
||||||
None,
|
None,
|
||||||
|
vec![],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -399,7 +400,14 @@ impl store::FromHistory for Proposal {
|
||||||
body,
|
body,
|
||||||
} => {
|
} => {
|
||||||
if let Some(revision) = lookup::revision(self, &revision)? {
|
if let Some(revision) = lookup::revision(self, &revision)? {
|
||||||
thread::edit(&mut revision.discussion, op.id, comment, op.timestamp, body)?;
|
thread::edit(
|
||||||
|
&mut revision.discussion,
|
||||||
|
op.id,
|
||||||
|
comment,
|
||||||
|
op.timestamp,
|
||||||
|
body,
|
||||||
|
vec![],
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::RevisionCommentRedact { revision, comment } => {
|
Action::RevisionCommentRedact { revision, comment } => {
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@ use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::cob;
|
use crate::cob;
|
||||||
use crate::cob::common::{Author, Label, Reaction, Timestamp};
|
use crate::cob::common::{Author, Label, Reaction, Timestamp, Uri};
|
||||||
use crate::cob::store::Transaction;
|
use crate::cob::store::Transaction;
|
||||||
use crate::cob::store::{FromHistory as _, HistoryAction};
|
use crate::cob::store::{FromHistory as _, HistoryAction};
|
||||||
use crate::cob::thread;
|
use crate::cob::thread;
|
||||||
use crate::cob::thread::{CommentId, Thread};
|
use crate::cob::thread::{CommentId, Thread};
|
||||||
use crate::cob::{store, EntryId, ObjectId, TypeName};
|
use crate::cob::{store, Embed, EntryId, ObjectId, TypeName};
|
||||||
use crate::crypto::Signer;
|
use crate::crypto::Signer;
|
||||||
use crate::prelude::{Did, ReadRepository};
|
use crate::prelude::{Did, ReadRepository};
|
||||||
use crate::storage::WriteRepository;
|
use crate::storage::WriteRepository;
|
||||||
|
|
@ -109,7 +109,7 @@ impl store::FromHistory for Issue {
|
||||||
type Error = Error;
|
type Error = Error;
|
||||||
|
|
||||||
fn type_name() -> &'static TypeName {
|
fn type_name() -> &'static TypeName {
|
||||||
&*TYPENAME
|
&TYPENAME
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), Self::Error> {
|
fn validate(&self) -> Result<(), Self::Error> {
|
||||||
|
|
@ -154,7 +154,11 @@ impl store::FromHistory for Issue {
|
||||||
Action::Label { labels } => {
|
Action::Label { labels } => {
|
||||||
self.labels = BTreeSet::from_iter(labels);
|
self.labels = BTreeSet::from_iter(labels);
|
||||||
}
|
}
|
||||||
Action::Comment { body, reply_to } => {
|
Action::Comment {
|
||||||
|
body,
|
||||||
|
reply_to,
|
||||||
|
embeds,
|
||||||
|
} => {
|
||||||
thread::comment(
|
thread::comment(
|
||||||
&mut self.thread,
|
&mut self.thread,
|
||||||
op.id,
|
op.id,
|
||||||
|
|
@ -163,10 +167,11 @@ impl store::FromHistory for Issue {
|
||||||
body,
|
body,
|
||||||
reply_to,
|
reply_to,
|
||||||
None,
|
None,
|
||||||
|
embeds,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
Action::CommentEdit { id, body } => {
|
Action::CommentEdit { id, body, embeds } => {
|
||||||
thread::edit(&mut self.thread, op.id, id, op.timestamp, body)?;
|
thread::edit(&mut self.thread, op.id, id, op.timestamp, body, embeds)?;
|
||||||
}
|
}
|
||||||
Action::CommentRedact { id } => {
|
Action::CommentRedact { id } => {
|
||||||
thread::redact(&mut self.thread, op.id, id)?;
|
thread::redact(&mut self.thread, op.id, id)?;
|
||||||
|
|
@ -252,10 +257,19 @@ impl store::Transaction<Issue> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Edit an issue comment.
|
/// Edit an issue comment.
|
||||||
pub fn edit_comment(&mut self, id: CommentId, body: impl ToString) -> Result<(), store::Error> {
|
pub fn edit_comment(
|
||||||
|
&mut self,
|
||||||
|
id: CommentId,
|
||||||
|
body: impl ToString,
|
||||||
|
embeds: Vec<Embed>,
|
||||||
|
) -> Result<(), store::Error> {
|
||||||
|
let hashed = embeds.iter().map(|e| e.hashed()).collect();
|
||||||
|
|
||||||
|
self.embed(embeds)?;
|
||||||
self.push(Action::CommentEdit {
|
self.push(Action::CommentEdit {
|
||||||
id,
|
id,
|
||||||
body: body.to_string(),
|
body: body.to_string(),
|
||||||
|
embeds: hashed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,10 +290,15 @@ impl store::Transaction<Issue> {
|
||||||
&mut self,
|
&mut self,
|
||||||
body: S,
|
body: S,
|
||||||
reply_to: CommentId,
|
reply_to: CommentId,
|
||||||
|
embeds: Vec<Embed>,
|
||||||
) -> Result<(), store::Error> {
|
) -> Result<(), store::Error> {
|
||||||
|
let hashed = embeds.iter().map(|e| e.hashed()).collect();
|
||||||
|
|
||||||
|
self.embed(embeds)?;
|
||||||
self.push(Action::Comment {
|
self.push(Action::Comment {
|
||||||
body: body.to_string(),
|
body: body.to_string(),
|
||||||
reply_to: Some(reply_to),
|
reply_to: Some(reply_to),
|
||||||
|
embeds: hashed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,10 +326,19 @@ impl store::Transaction<Issue> {
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
/// Create the issue thread.
|
/// Create the issue thread.
|
||||||
fn thread<S: ToString>(&mut self, body: S) -> Result<(), store::Error> {
|
fn thread<S: ToString>(
|
||||||
|
&mut self,
|
||||||
|
body: S,
|
||||||
|
embeds: impl IntoIterator<Item = Embed>,
|
||||||
|
) -> Result<(), store::Error> {
|
||||||
|
let embeds = embeds.into_iter().collect::<Vec<_>>();
|
||||||
|
let hashed = embeds.iter().map(|e| e.hashed()).collect();
|
||||||
|
|
||||||
|
self.embed(embeds)?;
|
||||||
self.push(Action::Comment {
|
self.push(Action::Comment {
|
||||||
body: body.to_string(),
|
body: body.to_string(),
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
|
embeds: hashed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -367,6 +395,7 @@ where
|
||||||
pub fn edit_description<G: Signer>(
|
pub fn edit_description<G: Signer>(
|
||||||
&mut self,
|
&mut self,
|
||||||
description: impl ToString,
|
description: impl ToString,
|
||||||
|
embeds: impl IntoIterator<Item = Embed>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<EntryId, Error> {
|
) -> Result<EntryId, Error> {
|
||||||
let Some((id, _)) = self.thread.comments().next() else {
|
let Some((id, _)) = self.thread.comments().next() else {
|
||||||
|
|
@ -374,7 +403,7 @@ where
|
||||||
};
|
};
|
||||||
let id = *id;
|
let id = *id;
|
||||||
self.transaction("Edit description", signer, |tx| {
|
self.transaction("Edit description", signer, |tx| {
|
||||||
tx.edit_comment(id, description)
|
tx.edit_comment(id, description, embeds.into_iter().collect())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,13 +417,16 @@ where
|
||||||
&mut self,
|
&mut self,
|
||||||
body: S,
|
body: S,
|
||||||
reply_to: CommentId,
|
reply_to: CommentId,
|
||||||
|
embeds: impl IntoIterator<Item = Embed>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<EntryId, Error> {
|
) -> Result<EntryId, Error> {
|
||||||
assert!(
|
assert!(
|
||||||
self.thread.comment(&reply_to).is_some(),
|
self.thread.comment(&reply_to).is_some(),
|
||||||
"Comment {reply_to} not found"
|
"Comment {reply_to} not found"
|
||||||
);
|
);
|
||||||
self.transaction("Comment", signer, |tx| tx.comment(body, reply_to))
|
self.transaction("Comment", signer, |tx| {
|
||||||
|
tx.comment(body, reply_to, embeds.into_iter().collect())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Label an issue.
|
/// Label an issue.
|
||||||
|
|
@ -502,10 +534,11 @@ where
|
||||||
description: impl ToString,
|
description: impl ToString,
|
||||||
labels: &[Label],
|
labels: &[Label],
|
||||||
assignees: &[Did],
|
assignees: &[Did],
|
||||||
|
embeds: impl IntoIterator<Item = Embed>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<IssueMut<'a, 'g, R>, Error> {
|
) -> Result<IssueMut<'a, 'g, R>, Error> {
|
||||||
let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
|
let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
|
||||||
tx.thread(description)?;
|
tx.thread(description, embeds)?;
|
||||||
tx.assign(assignees.to_owned())?;
|
tx.assign(assignees.to_owned())?;
|
||||||
tx.edit(title)?;
|
tx.edit(title)?;
|
||||||
tx.label(labels.to_owned())?;
|
tx.label(labels.to_owned())?;
|
||||||
|
|
@ -573,11 +606,21 @@ pub enum Action {
|
||||||
/// Should be the root [`CommentId`] if it's a top-level comment.
|
/// Should be the root [`CommentId`] if it's a top-level comment.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
reply_to: Option<CommentId>,
|
reply_to: Option<CommentId>,
|
||||||
|
/// Embeded content.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
embeds: Vec<Embed<Uri>>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Edit a comment.
|
/// Edit a comment.
|
||||||
#[serde(rename = "comment.edit")]
|
#[serde(rename = "comment.edit")]
|
||||||
CommentEdit { id: CommentId, body: String },
|
CommentEdit {
|
||||||
|
/// Comment being edited.
|
||||||
|
id: CommentId,
|
||||||
|
/// New value for the comment body.
|
||||||
|
body: String,
|
||||||
|
/// New value for the embeds list.
|
||||||
|
embeds: Vec<Embed<Uri>>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Redact a change. Not all changes can be redacted.
|
/// Redact a change. Not all changes can be redacted.
|
||||||
#[serde(rename = "comment.redact")]
|
#[serde(rename = "comment.redact")]
|
||||||
|
|
@ -600,6 +643,7 @@ mod test {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::cob::{ActorId, Reaction};
|
use crate::cob::{ActorId, Reaction};
|
||||||
|
use crate::git::Oid;
|
||||||
use crate::test;
|
use crate::test;
|
||||||
use crate::test::arbitrary;
|
use crate::test::arbitrary;
|
||||||
|
|
||||||
|
|
@ -611,7 +655,14 @@ mod test {
|
||||||
let mut eve_issues = Issues::open(&*t.eve.repo).unwrap();
|
let mut eve_issues = Issues::open(&*t.eve.repo).unwrap();
|
||||||
|
|
||||||
let mut issue_alice = issues_alice
|
let mut issue_alice = issues_alice
|
||||||
.create("Alice Issue", "Alice's comment", &[], &[], &t.alice.signer)
|
.create(
|
||||||
|
"Alice Issue",
|
||||||
|
"Alice's comment",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
[],
|
||||||
|
&t.alice.signer,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let id = *issue_alice.id();
|
let id = *issue_alice.id();
|
||||||
|
|
||||||
|
|
@ -622,10 +673,10 @@ mod test {
|
||||||
let mut issue_bob = bob_issues.get_mut(&id).unwrap();
|
let mut issue_bob = bob_issues.get_mut(&id).unwrap();
|
||||||
|
|
||||||
issue_bob
|
issue_bob
|
||||||
.comment("Bob's reply", id.into(), &t.bob.signer)
|
.comment("Bob's reply", id.into(), vec![], &t.bob.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
issue_alice
|
issue_alice
|
||||||
.comment("Alice's reply", id.into(), &t.alice.signer)
|
.comment("Alice's reply", id.into(), vec![], &t.alice.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(issue_bob.comments().count(), 2);
|
assert_eq!(issue_bob.comments().count(), 2);
|
||||||
|
|
@ -653,7 +704,7 @@ mod test {
|
||||||
t.eve.repo.fetch(&t.alice);
|
t.eve.repo.fetch(&t.alice);
|
||||||
|
|
||||||
let eve_reply = issue_eve
|
let eve_reply = issue_eve
|
||||||
.comment("Eve's reply", id.into(), &t.eve.signer)
|
.comment("Eve's reply", id.into(), vec![], &t.eve.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
t.bob.repo.fetch(&t.eve);
|
t.bob.repo.fetch(&t.eve);
|
||||||
|
|
@ -698,6 +749,7 @@ mod test {
|
||||||
"Blah blah blah.",
|
"Blah blah blah.",
|
||||||
&[],
|
&[],
|
||||||
&[assignee],
|
&[assignee],
|
||||||
|
[],
|
||||||
&node.signer,
|
&node.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -736,6 +788,7 @@ mod test {
|
||||||
"Blah blah blah.",
|
"Blah blah blah.",
|
||||||
&[],
|
&[],
|
||||||
&[assignee],
|
&[assignee],
|
||||||
|
[],
|
||||||
&node.signer,
|
&node.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -760,7 +813,14 @@ mod test {
|
||||||
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let mut issues = Issues::open(&*repo).unwrap();
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
let created = issues
|
let created = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
.create(
|
||||||
|
"My first issue",
|
||||||
|
"Blah blah blah.",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
[],
|
||||||
|
&node.signer,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (id, created) = (created.id, created.issue);
|
let (id, created) = (created.id, created.issue);
|
||||||
|
|
@ -779,7 +839,14 @@ mod test {
|
||||||
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let mut issues = Issues::open(&*repo).unwrap();
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
.create(
|
||||||
|
"My first issue",
|
||||||
|
"Blah blah blah.",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
[],
|
||||||
|
&node.signer,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
issue
|
issue
|
||||||
|
|
@ -820,6 +887,7 @@ mod test {
|
||||||
"Blah blah blah.",
|
"Blah blah blah.",
|
||||||
&[],
|
&[],
|
||||||
&[assignee, assignee_two],
|
&[assignee, assignee_two],
|
||||||
|
[],
|
||||||
&node.signer,
|
&node.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -839,7 +907,14 @@ mod test {
|
||||||
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let mut issues = Issues::open(&*repo).unwrap();
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
.create(
|
||||||
|
"My first issue",
|
||||||
|
"Blah blah blah.",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
[],
|
||||||
|
&node.signer,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
issue.edit("Sorry typo", &node.signer).unwrap();
|
issue.edit("Sorry typo", &node.signer).unwrap();
|
||||||
|
|
@ -856,11 +931,18 @@ mod test {
|
||||||
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let mut issues = Issues::open(&*repo).unwrap();
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
.create(
|
||||||
|
"My first issue",
|
||||||
|
"Blah blah blah.",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
[],
|
||||||
|
&node.signer,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
issue
|
issue
|
||||||
.edit_description("Bob Loblaw law blog", &node.signer)
|
.edit_description("Bob Loblaw law blog", vec![], &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
|
|
@ -875,7 +957,14 @@ mod test {
|
||||||
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let mut issues = Issues::open(&*repo).unwrap();
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
.create(
|
||||||
|
"My first issue",
|
||||||
|
"Blah blah blah.",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
[],
|
||||||
|
&node.signer,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (comment, _) = issue.root();
|
let (comment, _) = issue.root();
|
||||||
|
|
@ -897,13 +986,24 @@ mod test {
|
||||||
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let mut issues = Issues::open(&*repo).unwrap();
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
.create(
|
||||||
|
"My first issue",
|
||||||
|
"Blah blah blah.",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
[],
|
||||||
|
&node.signer,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let (root, _) = issue.root();
|
let (root, _) = issue.root();
|
||||||
let root = *root;
|
let root = *root;
|
||||||
|
|
||||||
let c1 = issue.comment("Hi hi hi.", root, &node.signer).unwrap();
|
let c1 = issue
|
||||||
let c2 = issue.comment("Ha ha ha.", root, &node.signer).unwrap();
|
.comment("Hi hi hi.", root, vec![], &node.signer)
|
||||||
|
.unwrap();
|
||||||
|
let c2 = issue
|
||||||
|
.comment("Ha ha ha.", root, vec![], &node.signer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
let mut issue = issues.get_mut(&id).unwrap();
|
let mut issue = issues.get_mut(&id).unwrap();
|
||||||
|
|
@ -913,10 +1013,14 @@ mod test {
|
||||||
assert_eq!(reply1.body(), "Hi hi hi.");
|
assert_eq!(reply1.body(), "Hi hi hi.");
|
||||||
assert_eq!(reply2.body(), "Ha ha ha.");
|
assert_eq!(reply2.body(), "Ha ha ha.");
|
||||||
|
|
||||||
issue.comment("Re: Hi.", c1, &node.signer).unwrap();
|
issue.comment("Re: Hi.", c1, vec![], &node.signer).unwrap();
|
||||||
issue.comment("Re: Ha.", c2, &node.signer).unwrap();
|
issue.comment("Re: Ha.", c2, vec![], &node.signer).unwrap();
|
||||||
issue.comment("Re: Ha. Ha.", c2, &node.signer).unwrap();
|
issue
|
||||||
issue.comment("Re: Ha. Ha. Ha.", c2, &node.signer).unwrap();
|
.comment("Re: Ha. Ha.", c2, vec![], &node.signer)
|
||||||
|
.unwrap();
|
||||||
|
issue
|
||||||
|
.comment("Re: Ha. Ha. Ha.", c2, vec![], &node.signer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
||||||
|
|
@ -942,6 +1046,7 @@ mod test {
|
||||||
"Blah blah blah.",
|
"Blah blah blah.",
|
||||||
&[ux_label.clone()],
|
&[ux_label.clone()],
|
||||||
&[],
|
&[],
|
||||||
|
[],
|
||||||
&node.signer,
|
&node.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -971,15 +1076,26 @@ mod test {
|
||||||
let author = *node.signer.public_key();
|
let author = *node.signer.public_key();
|
||||||
let mut issues = Issues::open(&*repo).unwrap();
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
.create(
|
||||||
|
"My first issue",
|
||||||
|
"Blah blah blah.",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
[],
|
||||||
|
&node.signer,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// The root thread op id is always the same.
|
// The root thread op id is always the same.
|
||||||
let (c0, _) = issue.root();
|
let (c0, _) = issue.root();
|
||||||
let c0 = *c0;
|
let c0 = *c0;
|
||||||
|
|
||||||
issue.comment("Ho ho ho.", c0, &node.signer).unwrap();
|
issue
|
||||||
issue.comment("Ha ha ha.", c0, &node.signer).unwrap();
|
.comment("Ho ho ho.", c0, vec![], &node.signer)
|
||||||
|
.unwrap();
|
||||||
|
issue
|
||||||
|
.comment("Ha ha ha.", c0, vec![], &node.signer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
@ -1017,13 +1133,13 @@ mod test {
|
||||||
let mut issues = Issues::open(&*repo).unwrap();
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
|
|
||||||
issues
|
issues
|
||||||
.create("First", "Blah", &[], &[], &node.signer)
|
.create("First", "Blah", &[], &[], [], &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
issues
|
issues
|
||||||
.create("Second", "Blah", &[], &[], &node.signer)
|
.create("Second", "Blah", &[], &[], [], &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
issues
|
issues
|
||||||
.create("Third", "Blah", &[], &[], &node.signer)
|
.create("Third", "Blah", &[], &[], [], &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let issues = issues
|
let issues = issues
|
||||||
|
|
@ -1050,6 +1166,7 @@ mod test {
|
||||||
"Blah blah blah.\nYah yah yah",
|
"Blah blah blah.\nYah yah yah",
|
||||||
&[],
|
&[],
|
||||||
&[],
|
&[],
|
||||||
|
[],
|
||||||
&node.signer,
|
&node.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -1064,4 +1181,106 @@ mod test {
|
||||||
assert_eq!(issue.comments().count(), 1);
|
assert_eq!(issue.comments().count(), 1);
|
||||||
assert_eq!(issue.state(), &State::Open);
|
assert_eq!(issue.state(), &State::Open);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_embeds() {
|
||||||
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
|
let embed1 = Embed {
|
||||||
|
name: String::from("example.html"),
|
||||||
|
content: b"<html>Hello World!</html>".to_vec(),
|
||||||
|
};
|
||||||
|
let embed2 = Embed {
|
||||||
|
name: String::from("style.css"),
|
||||||
|
content: b"body { color: red }".to_vec(),
|
||||||
|
};
|
||||||
|
let embed3 = Embed {
|
||||||
|
name: String::from("bin"),
|
||||||
|
content: vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||||
|
};
|
||||||
|
let mut issue = issues
|
||||||
|
.create(
|
||||||
|
"My first issue",
|
||||||
|
"Blah blah blah.",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
[embed1.clone(), embed2.clone()],
|
||||||
|
&node.signer,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
issue
|
||||||
|
.comment(
|
||||||
|
"Here's a binary file",
|
||||||
|
issue.id.into(),
|
||||||
|
[embed3.clone()],
|
||||||
|
&node.signer,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
issue.reload().unwrap();
|
||||||
|
|
||||||
|
let (_, c0) = issue.thread().comments().next().unwrap();
|
||||||
|
let (_, c1) = issue.thread().comments().next_back().unwrap();
|
||||||
|
|
||||||
|
let e1 = &c0.embeds()[0];
|
||||||
|
let e2 = &c0.embeds()[1];
|
||||||
|
let e3 = &c1.embeds()[0];
|
||||||
|
|
||||||
|
let b1 = repo.blob(Oid::try_from(&e1.content).unwrap()).unwrap();
|
||||||
|
let b2 = repo.blob(Oid::try_from(&e2.content).unwrap()).unwrap();
|
||||||
|
let b3 = repo.blob(Oid::try_from(&e3.content).unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(b1.content(), &embed1.content);
|
||||||
|
assert_eq!(b2.content(), &embed2.content);
|
||||||
|
assert_eq!(b3.content(), &embed3.content);
|
||||||
|
|
||||||
|
assert_eq!(b1.is_binary(), false);
|
||||||
|
assert_eq!(b2.is_binary(), false);
|
||||||
|
assert_eq!(b3.is_binary(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_embeds_edit() {
|
||||||
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
|
let embed1 = Embed {
|
||||||
|
name: String::from("example.html"),
|
||||||
|
content: b"<html>Hello World!</html>".to_vec(),
|
||||||
|
};
|
||||||
|
let embed1_edited = Embed {
|
||||||
|
name: String::from("example.html"),
|
||||||
|
content: b"<html>Hello Radicle!</html>".to_vec(),
|
||||||
|
};
|
||||||
|
let embed2 = Embed {
|
||||||
|
name: String::from("style.css"),
|
||||||
|
content: b"body { color: red }".to_vec(),
|
||||||
|
};
|
||||||
|
let mut issue = issues
|
||||||
|
.create(
|
||||||
|
"My first issue",
|
||||||
|
"Blah blah blah.",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
[embed1.clone(), embed2.clone()],
|
||||||
|
&node.signer,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
issue.reload().unwrap();
|
||||||
|
issue
|
||||||
|
.edit_description("My first issue", [embed1_edited.clone()], &node.signer)
|
||||||
|
.unwrap();
|
||||||
|
issue.reload().unwrap();
|
||||||
|
|
||||||
|
let (_, c0) = issue.thread().comments().next().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(c0.embeds().len(), 1);
|
||||||
|
|
||||||
|
let e1 = &c0.embeds()[0];
|
||||||
|
let b1 = repo.blob(Oid::try_from(&e1.content).unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(e1.content, Uri::from(embed1_edited.oid()));
|
||||||
|
assert_eq!(b1.content(), &embed1_edited.content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ impl store::FromHistory for Issue {
|
||||||
type Error = Error;
|
type Error = Error;
|
||||||
|
|
||||||
fn type_name() -> &'static TypeName {
|
fn type_name() -> &'static TypeName {
|
||||||
&*issue::TYPENAME
|
&issue::TYPENAME
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), Self::Error> {
|
fn validate(&self) -> Result<(), Self::Error> {
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ impl store::FromHistory for Patch {
|
||||||
type Error = Error;
|
type Error = Error;
|
||||||
|
|
||||||
fn type_name() -> &'static TypeName {
|
fn type_name() -> &'static TypeName {
|
||||||
&*TYPENAME
|
&TYPENAME
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), Self::Error> {
|
fn validate(&self) -> Result<(), Self::Error> {
|
||||||
|
|
@ -242,6 +242,7 @@ impl store::FromHistory for Patch {
|
||||||
comment,
|
comment,
|
||||||
timestamp,
|
timestamp,
|
||||||
body,
|
body,
|
||||||
|
vec![],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -277,6 +278,7 @@ impl store::FromHistory for Patch {
|
||||||
body,
|
body,
|
||||||
None,
|
None,
|
||||||
Some(location),
|
Some(location),
|
||||||
|
vec![],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -449,7 +449,7 @@ impl store::FromHistory for Patch {
|
||||||
type Error = Error;
|
type Error = Error;
|
||||||
|
|
||||||
fn type_name() -> &'static TypeName {
|
fn type_name() -> &'static TypeName {
|
||||||
&*TYPENAME
|
&TYPENAME
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), Self::Error> {
|
fn validate(&self) -> Result<(), Self::Error> {
|
||||||
|
|
@ -626,7 +626,14 @@ impl store::FromHistory for Patch {
|
||||||
body,
|
body,
|
||||||
} => {
|
} => {
|
||||||
if let Some(review) = lookup::review(self, &review)? {
|
if let Some(review) = lookup::review(self, &review)? {
|
||||||
thread::edit(&mut review.comments, op.id, comment, timestamp, body)?;
|
thread::edit(
|
||||||
|
&mut review.comments,
|
||||||
|
op.id,
|
||||||
|
comment,
|
||||||
|
timestamp,
|
||||||
|
body,
|
||||||
|
vec![],
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::ReviewCommentResolve { .. } => {
|
Action::ReviewCommentResolve { .. } => {
|
||||||
|
|
@ -650,6 +657,7 @@ impl store::FromHistory for Patch {
|
||||||
body,
|
body,
|
||||||
reply_to,
|
reply_to,
|
||||||
location,
|
location,
|
||||||
|
vec![],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -739,6 +747,7 @@ impl store::FromHistory for Patch {
|
||||||
body,
|
body,
|
||||||
reply_to,
|
reply_to,
|
||||||
None,
|
None,
|
||||||
|
vec![],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -748,7 +757,14 @@ impl store::FromHistory for Patch {
|
||||||
body,
|
body,
|
||||||
} => {
|
} => {
|
||||||
if let Some(revision) = lookup::revision(self, &revision)? {
|
if let Some(revision) = lookup::revision(self, &revision)? {
|
||||||
thread::edit(&mut revision.discussion, op.id, comment, op.timestamp, body)?;
|
thread::edit(
|
||||||
|
&mut revision.discussion,
|
||||||
|
op.id,
|
||||||
|
comment,
|
||||||
|
op.timestamp,
|
||||||
|
body,
|
||||||
|
vec![],
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::RevisionCommentRedact { revision, comment } => {
|
Action::RevisionCommentRedact { revision, comment } => {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::cob::common::Timestamp;
|
use crate::cob::common::Timestamp;
|
||||||
use crate::cob::op::Op;
|
use crate::cob::op::Op;
|
||||||
use crate::cob::{ActorId, Create, EntryId, History, ObjectId, TypeName, Update, Updated, Version};
|
use crate::cob::{
|
||||||
|
ActorId, Create, Embed, EntryId, History, ObjectId, TypeName, Update, Updated, Version,
|
||||||
|
};
|
||||||
use crate::git;
|
use crate::git;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::storage::git as storage;
|
use crate::storage::git as storage;
|
||||||
|
|
@ -165,6 +167,7 @@ where
|
||||||
object_id: ObjectId,
|
object_id: ObjectId,
|
||||||
message: &str,
|
message: &str,
|
||||||
actions: impl Into<NonEmpty<T::Action>>,
|
actions: impl Into<NonEmpty<T::Action>>,
|
||||||
|
embeds: Vec<Embed>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<Updated, Error> {
|
) -> Result<Updated, Error> {
|
||||||
let actions = actions.into();
|
let actions = actions.into();
|
||||||
|
|
@ -180,6 +183,7 @@ where
|
||||||
object_id,
|
object_id,
|
||||||
type_name: T::type_name().clone(),
|
type_name: T::type_name().clone(),
|
||||||
message: message.to_owned(),
|
message: message.to_owned(),
|
||||||
|
embeds,
|
||||||
changes,
|
changes,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -194,6 +198,7 @@ where
|
||||||
&self,
|
&self,
|
||||||
message: &str,
|
message: &str,
|
||||||
actions: impl Into<NonEmpty<T::Action>>,
|
actions: impl Into<NonEmpty<T::Action>>,
|
||||||
|
embeds: Vec<Embed>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<(ObjectId, T), Error> {
|
) -> Result<(ObjectId, T), Error> {
|
||||||
let actions = actions.into();
|
let actions = actions.into();
|
||||||
|
|
@ -209,6 +214,7 @@ where
|
||||||
type_name: T::type_name().clone(),
|
type_name: T::type_name().clone(),
|
||||||
version: Version::default(),
|
version: Version::default(),
|
||||||
message: message.to_owned(),
|
message: message.to_owned(),
|
||||||
|
embeds,
|
||||||
contents,
|
contents,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -288,6 +294,7 @@ where
|
||||||
pub struct Transaction<T: FromHistory> {
|
pub struct Transaction<T: FromHistory> {
|
||||||
actor: ActorId,
|
actor: ActorId,
|
||||||
actions: Vec<T::Action>,
|
actions: Vec<T::Action>,
|
||||||
|
embeds: Vec<Embed>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: FromHistory> Transaction<T> {
|
impl<T: FromHistory> Transaction<T> {
|
||||||
|
|
@ -296,6 +303,7 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
Self {
|
Self {
|
||||||
actor,
|
actor,
|
||||||
actions: Vec::new(),
|
actions: Vec::new(),
|
||||||
|
embeds: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,15 +321,13 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
T::Action: Serialize + Clone,
|
T::Action: Serialize + Clone,
|
||||||
{
|
{
|
||||||
let actor = *signer.public_key();
|
let actor = *signer.public_key();
|
||||||
let mut tx = Transaction {
|
let mut tx = Transaction::new(actor);
|
||||||
actor,
|
|
||||||
actions: Vec::new(),
|
|
||||||
};
|
|
||||||
operations(&mut tx)?;
|
operations(&mut tx)?;
|
||||||
|
|
||||||
let actions = NonEmpty::from_vec(tx.actions)
|
let actions = NonEmpty::from_vec(tx.actions)
|
||||||
.expect("Transaction::initial: transaction must contain at least one operation");
|
.expect("Transaction::initial: transaction must contain at least one operation");
|
||||||
let (id, cob) = store.create(message, actions, signer)?;
|
let (id, cob) = store.create(message, actions, tx.embeds, signer)?;
|
||||||
|
|
||||||
Ok((id, cob))
|
Ok((id, cob))
|
||||||
}
|
}
|
||||||
|
|
@ -333,6 +339,13 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Embed media into the transaction.
|
||||||
|
pub fn embed(&mut self, embeds: impl IntoIterator<Item = Embed>) -> Result<(), Error> {
|
||||||
|
self.embeds.extend(embeds);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Commit transaction.
|
/// Commit transaction.
|
||||||
///
|
///
|
||||||
/// Returns an operation that can be applied onto an in-memory state.
|
/// Returns an operation that can be applied onto an in-memory state.
|
||||||
|
|
@ -353,7 +366,7 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
head,
|
head,
|
||||||
object,
|
object,
|
||||||
parents,
|
parents,
|
||||||
} = store.update(id, msg, actions.clone(), signer)?;
|
} = store.update(id, msg, actions.clone(), self.embeds, signer)?;
|
||||||
let id = EntryId::from(head);
|
let id = EntryId::from(head);
|
||||||
let author = self.actor;
|
let author = self.actor;
|
||||||
let timestamp = Timestamp::from_secs(object.history().timestamp());
|
let timestamp = Timestamp::from_secs(object.history().timestamp());
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ use serde::{ser::SerializeStruct, Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::cob;
|
use crate::cob;
|
||||||
use crate::cob::common::{Reaction, Timestamp};
|
use crate::cob::common::{Reaction, Timestamp, Uri};
|
||||||
use crate::cob::{ActorId, EntryId, Op};
|
use crate::cob::{ActorId, Embed, EntryId, Op};
|
||||||
use crate::prelude::ReadRepository;
|
use crate::prelude::ReadRepository;
|
||||||
|
|
||||||
/// Type name of a thread, as well as the domain for all thread operations.
|
/// Type name of a thread, as well as the domain for all thread operations.
|
||||||
|
|
@ -49,6 +49,8 @@ pub struct Edit {
|
||||||
pub timestamp: Timestamp,
|
pub timestamp: Timestamp,
|
||||||
/// Edit contents. Replaces previous edits.
|
/// Edit contents. Replaces previous edits.
|
||||||
pub body: String,
|
pub body: String,
|
||||||
|
/// Edit embed list.
|
||||||
|
pub embeds: Vec<Embed<Uri>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A comment on a discussion thread.
|
/// A comment on a discussion thread.
|
||||||
|
|
@ -72,7 +74,7 @@ impl<T: Serialize> Serialize for Comment<T> {
|
||||||
where
|
where
|
||||||
S: serde::ser::Serializer,
|
S: serde::ser::Serializer,
|
||||||
{
|
{
|
||||||
let mut state = serializer.serialize_struct("Comment", 5)?;
|
let mut state = serializer.serialize_struct("Comment", 6)?;
|
||||||
state.serialize_field("author", &self.author())?;
|
state.serialize_field("author", &self.author())?;
|
||||||
if let Some(loc) = &self.location {
|
if let Some(loc) = &self.location {
|
||||||
state.serialize_field("location", loc)?;
|
state.serialize_field("location", loc)?;
|
||||||
|
|
@ -82,6 +84,7 @@ impl<T: Serialize> Serialize for Comment<T> {
|
||||||
}
|
}
|
||||||
state.serialize_field("reactions", &self.reactions)?;
|
state.serialize_field("reactions", &self.reactions)?;
|
||||||
state.serialize_field("body", self.body())?;
|
state.serialize_field("body", self.body())?;
|
||||||
|
state.serialize_field("embeds", self.embeds())?;
|
||||||
state.end()
|
state.end()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,9 +96,14 @@ impl<L> Comment<L> {
|
||||||
body: String,
|
body: String,
|
||||||
reply_to: Option<CommentId>,
|
reply_to: Option<CommentId>,
|
||||||
location: Option<L>,
|
location: Option<L>,
|
||||||
|
embeds: Vec<Embed<Uri>>,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let edit = Edit { body, timestamp };
|
let edit = Edit {
|
||||||
|
body,
|
||||||
|
embeds,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
author,
|
author,
|
||||||
|
|
@ -139,8 +147,12 @@ impl<L> Comment<L> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an edit.
|
/// Add an edit.
|
||||||
pub fn edit(&mut self, body: String, timestamp: Timestamp) {
|
pub fn edit(&mut self, body: String, embeds: Vec<Embed<Uri>>, timestamp: Timestamp) {
|
||||||
self.edits.push(Edit { body, timestamp });
|
self.edits.push(Edit {
|
||||||
|
body,
|
||||||
|
embeds,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Comment reactions.
|
/// Comment reactions.
|
||||||
|
|
@ -152,6 +164,14 @@ impl<L> Comment<L> {
|
||||||
pub fn location(&self) -> Option<&L> {
|
pub fn location(&self) -> Option<&L> {
|
||||||
self.location.as_ref()
|
self.location.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the embedded media.
|
||||||
|
pub fn embeds(&self) -> &[Embed<Uri>] {
|
||||||
|
// SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
|
||||||
|
// constructor.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
&self.edits.last().unwrap().embeds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: PartialOrd> PartialOrd for Comment<T> {
|
impl<T: PartialOrd> PartialOrd for Comment<T> {
|
||||||
|
|
@ -283,7 +303,7 @@ impl cob::store::FromHistory for Thread {
|
||||||
type Error = Error;
|
type Error = Error;
|
||||||
|
|
||||||
fn type_name() -> &'static radicle_cob::TypeName {
|
fn type_name() -> &'static radicle_cob::TypeName {
|
||||||
&*TYPENAME
|
&TYPENAME
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), Self::Error> {
|
fn validate(&self) -> Result<(), Self::Error> {
|
||||||
|
|
@ -301,10 +321,10 @@ impl cob::store::FromHistory for Thread {
|
||||||
for action in op.actions {
|
for action in op.actions {
|
||||||
match action {
|
match action {
|
||||||
Action::Comment { body, reply_to } => {
|
Action::Comment { body, reply_to } => {
|
||||||
comment(self, id, author, timestamp, body, reply_to, None)?;
|
comment(self, id, author, timestamp, body, reply_to, None, vec![])?;
|
||||||
}
|
}
|
||||||
Action::Edit { id, body } => {
|
Action::Edit { id, body } => {
|
||||||
edit(self, op.id, id, timestamp, body)?;
|
edit(self, op.id, id, timestamp, body, vec![])?;
|
||||||
}
|
}
|
||||||
Action::Redact { id } => {
|
Action::Redact { id } => {
|
||||||
redact(self, op.id, id)?;
|
redact(self, op.id, id)?;
|
||||||
|
|
@ -330,6 +350,7 @@ pub fn comment<L>(
|
||||||
body: String,
|
body: String,
|
||||||
reply_to: Option<CommentId>,
|
reply_to: Option<CommentId>,
|
||||||
location: Option<L>,
|
location: Option<L>,
|
||||||
|
embeds: Vec<Embed<Uri>>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
if body.is_empty() {
|
if body.is_empty() {
|
||||||
return Err(Error::Comment(id));
|
return Err(Error::Comment(id));
|
||||||
|
|
@ -341,7 +362,9 @@ pub fn comment<L>(
|
||||||
// underlying store guarantees exactly-once delivery of ops.
|
// underlying store guarantees exactly-once delivery of ops.
|
||||||
thread.comments.insert(
|
thread.comments.insert(
|
||||||
id,
|
id,
|
||||||
Some(Comment::new(author, body, reply_to, location, timestamp)),
|
Some(Comment::new(
|
||||||
|
author, body, reply_to, location, embeds, timestamp,
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -353,6 +376,7 @@ pub fn edit<L>(
|
||||||
comment: EntryId,
|
comment: EntryId,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
body: String,
|
body: String,
|
||||||
|
embeds: Vec<Embed<Uri>>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
if body.is_empty() {
|
if body.is_empty() {
|
||||||
return Err(Error::Edit(id));
|
return Err(Error::Edit(id));
|
||||||
|
|
@ -367,7 +391,7 @@ pub fn edit<L>(
|
||||||
// that as an error.
|
// that as an error.
|
||||||
if let Some(comment) = thread.comments.get_mut(&comment) {
|
if let Some(comment) = thread.comments.get_mut(&comment) {
|
||||||
if let Some(comment) = comment {
|
if let Some(comment) = comment {
|
||||||
comment.edit(body, timestamp);
|
comment.edit(body, embeds, timestamp);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err(Error::Missing(comment));
|
return Err(Error::Missing(comment));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#![allow(clippy::match_like_matches_macro)]
|
#![allow(clippy::match_like_matches_macro)]
|
||||||
#![allow(clippy::explicit_auto_deref)] // TODO: This can be removed when the clippy bugs are fixed
|
#![allow(clippy::too_many_arguments)]
|
||||||
#![allow(clippy::iter_nth_zero)]
|
#![allow(clippy::iter_nth_zero)]
|
||||||
|
|
||||||
pub extern crate radicle_crypto as crypto;
|
pub extern crate radicle_crypto as crypto;
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,9 @@ pub trait ReadRepository: Sized {
|
||||||
fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path)
|
fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path)
|
||||||
-> Result<git2::Blob<'a>, git_ext::Error>;
|
-> Result<git2::Blob<'a>, git_ext::Error>;
|
||||||
|
|
||||||
|
/// Get a blob in this repository, given its id.
|
||||||
|
fn blob(&self, oid: Oid) -> Result<git2::Blob, git_ext::Error>;
|
||||||
|
|
||||||
/// Validate all remotes with [`ReadRepository::validate_remote`].
|
/// Validate all remotes with [`ReadRepository::validate_remote`].
|
||||||
fn validate(&self) -> Result<(), VerifyError> {
|
fn validate(&self) -> Result<(), VerifyError> {
|
||||||
for (_, remote) in self.remotes()? {
|
for (_, remote) in self.remotes()? {
|
||||||
|
|
|
||||||
|
|
@ -357,14 +357,18 @@ impl ReadRepository for Repository {
|
||||||
self.backend.path()
|
self.backend.path()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result<git2::Blob<'a>, git::Error> {
|
fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path) -> Result<git2::Blob<'a>, git::Error> {
|
||||||
git::ext::Blob::At {
|
git::ext::Blob::At {
|
||||||
object: oid.into(),
|
object: commit.into(),
|
||||||
path,
|
path,
|
||||||
}
|
}
|
||||||
.get(&self.backend)
|
.get(&self.backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn blob(&self, oid: Oid) -> Result<git2::Blob, git::Error> {
|
||||||
|
self.backend.find_blob(oid.into()).map_err(git::Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_remote(&self, remote: &Remote<Verified>) -> Result<Vec<RefString>, VerifyError> {
|
fn validate_remote(&self, remote: &Remote<Verified>) -> Result<Vec<RefString>, VerifyError> {
|
||||||
// Contains a copy of the signed refs of this remote.
|
// Contains a copy of the signed refs of this remote.
|
||||||
let mut signed = BTreeMap::from((*remote.refs).clone());
|
let mut signed = BTreeMap::from((*remote.refs).clone());
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,10 @@ impl<'a> ReadRepository for DraftStore<'a> {
|
||||||
self.repo.blob_at(oid, path)
|
self.repo.blob_at(oid, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn blob(&self, oid: git_ext::Oid) -> Result<raw::Blob, ext::Error> {
|
||||||
|
self.repo.blob(oid)
|
||||||
|
}
|
||||||
|
|
||||||
fn reference(
|
fn reference(
|
||||||
&self,
|
&self,
|
||||||
remote: &RemoteId,
|
remote: &RemoteId,
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,10 @@ impl ReadRepository for MockRepository {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn blob(&self, _oid: Oid) -> Result<git2::Blob, git_ext::Error> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
fn blob_at<'a>(
|
fn blob_at<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
_oid: git_ext::Oid,
|
_oid: git_ext::Oid,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue