From 534503d002c45f8d5a74a995e64a76f49d3f363c Mon Sep 17 00:00:00 2001 From: cloudhead Date: Thu, 17 Aug 2023 12:23:20 +0200 Subject: [PATCH] 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` 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. --- Cargo.lock | 1 + radicle-cli/src/commands/comment.rs | 2 +- radicle-cli/src/commands/issue.rs | 7 +- radicle-cli/tests/commands.rs | 2 + radicle-cob/Cargo.toml | 1 + radicle-cob/src/backend/git/change.rs | 63 +++- radicle-cob/src/change/store.rs | 36 +++ radicle-cob/src/change_graph.rs | 6 +- radicle-cob/src/lib.rs | 5 +- .../src/object/collaboration/create.rs | 19 +- radicle-cob/src/object/collaboration/info.rs | 2 +- .../src/object/collaboration/update.rs | 8 +- radicle-cob/src/tests.rs | 7 + radicle-cob/src/trailers.rs | 2 + radicle-httpd/src/api/v1/projects.rs | 5 +- radicle-httpd/src/test.rs | 1 + radicle-node/src/test/environment.rs | 2 +- radicle/src/cob.rs | 2 +- radicle/src/cob/common.rs | 52 ++++ radicle/src/cob/identity.rs | 12 +- radicle/src/cob/issue.rs | 289 +++++++++++++++--- radicle/src/cob/legacy/issue.rs | 2 +- radicle/src/cob/legacy/patch.rs | 4 +- radicle/src/cob/patch.rs | 22 +- radicle/src/cob/store.rs | 27 +- radicle/src/cob/thread.rs | 46 ++- radicle/src/lib.rs | 2 +- radicle/src/storage.rs | 3 + radicle/src/storage/git.rs | 8 +- radicle/src/storage/git/cob.rs | 4 + radicle/src/test/storage.rs | 4 + 31 files changed, 545 insertions(+), 101 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 006f953a..54520952 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1924,6 +1924,7 @@ dependencies = [ "git2", "log", "nonempty 0.8.1", + "once_cell", "qcheck", "qcheck-macros", "radicle-crypto", diff --git a/radicle-cli/src/commands/comment.rs b/radicle-cli/src/commands/comment.rs index 0325f199..72e110f1 100644 --- a/radicle-cli/src/commands/comment.rs +++ b/radicle-cli/src/commands/comment.rs @@ -110,7 +110,7 @@ fn comment( let (comment_id, _) = issue.comments().next().expect("root comment always exists"); *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); return Ok(()); diff --git a/radicle-cli/src/commands/issue.rs b/radicle-cli/src/commands/issue.rs index 622d01e4..c0847433 100644 --- a/radicle-cli/src/commands/issue.rs +++ b/radicle-cli/src/commands/issue.rs @@ -295,7 +295,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { description: Some(description), labels, } => { - let issue = issues.create(title, description, labels.as_slice(), &[], &signer)?; + let issue = issues.create(title, description, labels.as_slice(), &[], [], &signer)?; if !options.quiet { show_issue(&issue, issue.id())?; } @@ -569,6 +569,7 @@ fn open( description.trim(), meta.labels.as_slice(), meta.assignees.as_slice(), + [], signer, )?; if !options.quiet { @@ -598,7 +599,7 @@ fn edit( tx.edit(t)?; } if let Some(d) = description { - tx.edit_comment(desc_id, d)?; + tx.edit_comment(desc_id, d, vec![])?; } Ok(()) @@ -621,7 +622,7 @@ fn edit( issue.transaction("Edit", signer, |tx| { tx.edit(edited.title)?; - tx.edit_comment(desc_id, description)?; + tx.edit_comment(desc_id, description, vec![])?; tx.label(edited.labels)?; tx.assign(edited.assignees)?; diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs index 1d32f117..deb71a36 100644 --- a/radicle-cli/tests/commands.rs +++ b/radicle-cli/tests/commands.rs @@ -794,6 +794,7 @@ fn test_cob_replication() { "I don't know what it is", &[], &[], + [], &bob.signer, ) .unwrap(); @@ -843,6 +844,7 @@ fn test_cob_deletion() { "I don't know what it is", &[], &[], + [], &alice.signer, ) .unwrap(); diff --git a/radicle-cob/Cargo.toml b/radicle-cob/Cargo.toml index aa6389c2..ea716e77 100644 --- a/radicle-cob/Cargo.toml +++ b/radicle-cob/Cargo.toml @@ -16,6 +16,7 @@ keywords = ["radicle", "cob", "cobs"] fastrand = { version = "1.9.0" } log = { version = "0.4.17" } nonempty = { version = "0.8.1", features = ["serialize"] } +once_cell = { version = "1.13" } radicle-git-ext = { version = "0.6.0", features = ["serde"] } serde_json = { version = "1.0" } thiserror = { version = "1.0" } diff --git a/radicle-cob/src/backend/git/change.rs b/radicle-cob/src/backend/git/change.rs index 469e78fc..a6488e0e 100644 --- a/radicle-cob/src/backend/git/change.rs +++ b/radicle-cob/src/backend/git/change.rs @@ -2,11 +2,13 @@ use std::collections::BTreeMap; use std::convert::TryFrom; +use std::path::PathBuf; use git_ext::author::Author; use git_ext::commit::{headers::Headers, Commit}; use git_ext::Oid; use nonempty::NonEmpty; +use once_cell::sync::Lazy; use radicle_git_ext::commit::trailers::OwnedTrailer; use crate::change::store::Version; @@ -16,10 +18,13 @@ use crate::{ change::{self, store, Change}, history::entry, 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 = Lazy::new(|| PathBuf::from("embeds")); pub mod error { use std::str::Utf8Error; @@ -102,10 +107,11 @@ impl change::Storage for git2::Repository { type_name, tips, message, + embeds, contents, } = spec; 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 signature = { let sig = signer.sign(revision.as_bytes()); @@ -281,6 +287,8 @@ where #[cfg(debug_assertions)] 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::().unwrap(); let author = Author { time: git_ext::author::Time::new(timestamp, 0), @@ -308,24 +316,47 @@ where fn write_manifest( repo: &git2::Repository, manifest: &store::Manifest, + embeds: Vec, contents: &NonEmpty>, ) -> Result { - let mut tb = repo.treebuilder(None)?; - // 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 - let serialized_manifest = serde_json::to_vec(manifest).unwrap(); - let manifest_oid = repo.blob(&serialized_manifest)?; - tb.insert( - MANIFEST_BLOB_NAME, - manifest_oid, - git2::FileMode::Blob.into(), - )?; + 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 + // errors here is a programming error, which we can't recover from. + #[allow(clippy::unwrap_used)] + let manifest = serde_json::to_vec(manifest).unwrap(); + let manifest_oid = repo.blob(&manifest)?; + + root.insert( + MANIFEST_BLOB_NAME, + manifest_oid, + git2::FileMode::Blob.into(), + )?; + } + + // Insert each COB entry. for (ix, op) in contents.iter().enumerate() { 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) } diff --git a/radicle-cob/src/change/store.rs b/radicle-cob/src/change/store.rs index 2af6fc4a..663898fe 100644 --- a/radicle-cob/src/change/store.rs +++ b/radicle-cob/src/change/store.rs @@ -48,6 +48,7 @@ pub struct Template { pub type_name: TypeName, pub tips: Vec, pub message: String, + pub embeds: Vec, pub contents: NonEmpty>, } @@ -179,3 +180,38 @@ impl Version { NonZeroUsize::new(version).map(Self) } } + +/// Embedded object. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Embed> { + /// File name. + pub name: String, + /// File content or content hash. + pub content: T, +} + +impl Embed> { + /// 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>(&self) -> Embed { + Embed { + name: self.name.clone(), + content: T::from(self.oid()), + } + } +} + +impl Embed { + /// Get the object id of the embedded content. + pub fn oid(&self) -> Oid { + self.content + } +} diff --git a/radicle-cob/src/change_graph.rs b/radicle-cob/src/change_graph.rs index 41ebba25..8bae80ff 100644 --- a/radicle-cob/src/change_graph.rs +++ b/radicle-cob/src/change_graph.rs @@ -1,7 +1,7 @@ // Copyright © 2021 The Radicle Link Contributors +use std::collections::BTreeSet; use std::ops::ControlFlow; -use std::{collections::BTreeSet, convert::TryInto}; use git_ext::Oid; use radicle_dag::Dag; @@ -135,8 +135,8 @@ impl ChangeGraph { self.graph.tips().map(|(_, change)| *change.id()).collect() } - pub(crate) fn number_of_nodes(&self) -> u64 { - self.graph.len().try_into().unwrap() + pub(crate) fn number_of_nodes(&self) -> usize { + self.graph.len() } } diff --git a/radicle-cob/src/lib.rs b/radicle-cob/src/lib.rs index 979fb7e1..61d683ac 100644 --- a/radicle-cob/src/lib.rs +++ b/radicle-cob/src/lib.rs @@ -1,5 +1,6 @@ // Copyright © 2021 The Radicle Link Contributors +#![warn(clippy::unwrap_used)] //! # Collaborative Objects //! //! Collaborative objects are graphs of CRDTs. The current CRDTs that @@ -80,7 +81,7 @@ mod change_graph; mod trailers; pub mod change; -pub use change::store::{Manifest, Version}; +pub use change::store::{Embed, Manifest, Version}; pub use change::Change; pub mod history; @@ -98,9 +99,11 @@ pub use object::{ }; #[cfg(test)] +#[allow(clippy::unwrap_used)] mod test; #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests; /// The `Store` is an aggregation of the different types of storage diff --git a/radicle-cob/src/object/collaboration/create.rs b/radicle-cob/src/object/collaboration/create.rs index 661d10e5..ac0a6a5d 100644 --- a/radicle-cob/src/object/collaboration/create.rs +++ b/radicle-cob/src/object/collaboration/create.rs @@ -2,6 +2,7 @@ use nonempty::NonEmpty; +use crate::Embed; use crate::Store; use super::*; @@ -14,17 +15,20 @@ pub struct Create { pub type_name: TypeName, /// The message to add when creating this object. pub message: String, + /// Embedded content. + pub embeds: Vec, /// COB version. pub version: Version, } impl Create { - fn template(&self) -> change::Template { + fn template(self) -> change::Template { change::Template { - type_name: self.type_name.clone(), + type_name: self.type_name, tips: Vec::new(), - message: self.message.clone(), - contents: self.contents.clone(), + message: self.message, + embeds: self.embeds, + contents: self.contents, } } } @@ -59,14 +63,15 @@ where S: Store, G: crypto::Signer, { - let Create { type_name, .. } = &args; + let type_name = args.type_name.clone(); + let version = args.version; let init_change = storage .store(resource, parents, signer, args.template()) .map_err(error::Create::from)?; let object_id = init_change.id().into(); 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) })?; let history = History::new_from_root( @@ -79,7 +84,7 @@ where ); Ok(CollaborativeObject { - manifest: Manifest::new(args.type_name, args.version), + manifest: Manifest::new(type_name, version), history, id: object_id, }) diff --git a/radicle-cob/src/object/collaboration/info.rs b/radicle-cob/src/object/collaboration/info.rs index 211196a3..2172efa8 100644 --- a/radicle-cob/src/object/collaboration/info.rs +++ b/radicle-cob/src/object/collaboration/info.rs @@ -17,7 +17,7 @@ pub struct ChangeGraphInfo { /// The ID of the object pub object_id: ObjectId, /// 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 /// references to the object pub tips: BTreeSet, diff --git a/radicle-cob/src/object/collaboration/update.rs b/radicle-cob/src/object/collaboration/update.rs index 48fef860..294f004f 100644 --- a/radicle-cob/src/object/collaboration/update.rs +++ b/radicle-cob/src/object/collaboration/update.rs @@ -4,8 +4,8 @@ use git_ext::Oid; use nonempty::NonEmpty; use crate::{ - change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, ObjectId, Store, - TypeName, + change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, Embed, ObjectId, + Store, TypeName, }; use super::error; @@ -31,6 +31,8 @@ pub struct Update { pub type_name: TypeName, /// The message to add when updating this object. pub message: String, + /// Embedded files. + pub embeds: Vec, } /// Update an existing [`CollaborativeObject`]. @@ -67,6 +69,7 @@ where let Update { type_name: ref typename, object_id, + embeds, changes, message, } = args; @@ -85,6 +88,7 @@ where signer, change::Template { tips: object.tips().iter().cloned().collect(), + embeds, contents: changes, type_name: typename.clone(), message, diff --git a/radicle-cob/src/tests.rs b/radicle-cob/src/tests.rs index 9ee56dfa..68506e7b 100644 --- a/radicle-cob/src/tests.rs +++ b/radicle-cob/src/tests.rs @@ -34,6 +34,7 @@ fn roundtrip() { contents: nonempty!(Vec::new()), type_name: typename.clone(), message: "creating xyz.rad.issue".to_string(), + embeds: vec![], version: Version::default(), }, ) @@ -67,6 +68,7 @@ fn list_cobs() { contents: nonempty!(b"issue 1".to_vec()), type_name: typename.clone(), message: "creating xyz.rad.issue".to_string(), + embeds: vec![], version: Version::default(), }, ) @@ -82,6 +84,7 @@ fn list_cobs() { contents: nonempty!(b"issue 2".to_vec()), type_name: typename.clone(), message: "commenting xyz.rad.issue".to_string(), + embeds: vec![], version: Version::default(), }, ) @@ -117,6 +120,7 @@ fn update_cob() { contents: nonempty!(Vec::new()), type_name: typename.clone(), message: "creating xyz.rad.issue".to_string(), + embeds: vec![], version: Version::default(), }, ) @@ -136,6 +140,7 @@ fn update_cob() { changes: nonempty!(b"issue 1".to_vec()), object_id: *cob.id(), type_name: typename.clone(), + embeds: vec![], message: "commenting xyz.rad.issue".to_string(), }, ) @@ -176,6 +181,7 @@ fn traverse_cobs() { contents: nonempty!(b"issue 1".to_vec()), type_name: typename.clone(), message: "creating xyz.rad.issue".to_string(), + embeds: vec![], version: Version::default(), }, ) @@ -199,6 +205,7 @@ fn traverse_cobs() { changes: nonempty!(b"issue 2".to_vec()), object_id: *cob.id(), type_name: typename, + embeds: vec![], message: "commenting on xyz.rad.issue".to_string(), }, ) diff --git a/radicle-cob/src/trailers.rs b/radicle-cob/src/trailers.rs index 8ea84b3f..7374e764 100644 --- a/radicle-cob/src/trailers.rs +++ b/radicle-cob/src/trailers.rs @@ -58,6 +58,8 @@ impl From for ResourceCommitTrailer { impl From for Trailer<'_> { fn from(containing: ResourceCommitTrailer) -> Self { Trailer { + // SAFETY: "Rad-Resource" is a valid `Token`. + #[allow(clippy::unwrap_used)] token: Token::try_from("Rad-Resource").unwrap(), value: containing.0.to_string().into(), } diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index 9b930e72..d9e235fc 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -507,6 +507,7 @@ async fn issue_create_handler( issue.description, &issue.labels, &issue.assignees, + [], &signer, ) .map_err(Error::from)?; @@ -546,9 +547,9 @@ async fn issue_update_handler( issue::Action::Edit { title } => { issue.edit(title, &signer)?; } - issue::Action::Comment { body, reply_to } => { + issue::Action::Comment { body, reply_to, .. } => { if let Some(to) = reply_to { - issue.comment(body, to, &signer)?; + issue.comment(body, to, [], &signer)?; } else { return Err(Error::BadRequest("`replyTo` missing".to_owned())); } diff --git a/radicle-httpd/src/test.rs b/radicle-httpd/src/test.rs index 641096e2..08e52112 100644 --- a/radicle-httpd/src/test.rs +++ b/radicle-httpd/src/test.rs @@ -185,6 +185,7 @@ fn seed_with_signer(dir: &Path, profile: radicle::Profile, signer: &G "Change 'hello world' to 'hello everyone'".to_string(), &[], &[], + [], signer, ) .unwrap(); diff --git a/radicle-node/src/test/environment.rs b/radicle-node/src/test/environment.rs index 98d8802d..fc264d62 100644 --- a/radicle-node/src/test/environment.rs +++ b/radicle-node/src/test/environment.rs @@ -315,7 +315,7 @@ impl NodeHandle { let repo = self.storage.repository(rid).unwrap(); let mut issues = issue::Issues::open(&repo).unwrap(); *issues - .create(title, desc, &[], &[], &self.signer) + .create(title, desc, &[], &[], [], &self.signer) .unwrap() .id() } diff --git a/radicle/src/cob.rs b/radicle/src/cob.rs index 7eb06342..74847554 100644 --- a/radicle/src/cob.rs +++ b/radicle/src/cob.rs @@ -12,7 +12,7 @@ pub mod test; pub use cob::{ 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 common::*; diff --git a/radicle/src/cob/common.rs b/radicle/src/cob/common.rs index de9c5812..e2b25d23 100644 --- a/radicle/src/cob/common.rs +++ b/radicle/src/cob/common.rs @@ -4,6 +4,7 @@ use std::str::FromStr; use localtime::LocalTime; use serde::{Deserialize, Serialize}; +use crate::git_ext::Oid; use crate::prelude::*; /// 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 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 { + 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 { + 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)] mod test { use super::*; diff --git a/radicle/src/cob/identity.rs b/radicle/src/cob/identity.rs index 48018d00..06202685 100644 --- a/radicle/src/cob/identity.rs +++ b/radicle/src/cob/identity.rs @@ -320,7 +320,7 @@ impl store::FromHistory for Proposal { type Error = ApplyError; fn type_name() -> &'static TypeName { - &*TYPENAME + &TYPENAME } fn validate(&self) -> Result<(), Self::Error> { @@ -390,6 +390,7 @@ impl store::FromHistory for Proposal { body, reply_to, None, + vec![], )?; } } @@ -399,7 +400,14 @@ impl store::FromHistory for Proposal { body, } => { 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 } => { diff --git a/radicle/src/cob/issue.rs b/radicle/src/cob/issue.rs index f2784590..8900bdba 100644 --- a/radicle/src/cob/issue.rs +++ b/radicle/src/cob/issue.rs @@ -7,12 +7,12 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; 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::{FromHistory as _, HistoryAction}; use crate::cob::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::prelude::{Did, ReadRepository}; use crate::storage::WriteRepository; @@ -109,7 +109,7 @@ impl store::FromHistory for Issue { type Error = Error; fn type_name() -> &'static TypeName { - &*TYPENAME + &TYPENAME } fn validate(&self) -> Result<(), Self::Error> { @@ -154,7 +154,11 @@ impl store::FromHistory for Issue { Action::Label { labels } => { self.labels = BTreeSet::from_iter(labels); } - Action::Comment { body, reply_to } => { + Action::Comment { + body, + reply_to, + embeds, + } => { thread::comment( &mut self.thread, op.id, @@ -163,10 +167,11 @@ impl store::FromHistory for Issue { body, reply_to, None, + embeds, )?; } - Action::CommentEdit { id, body } => { - thread::edit(&mut self.thread, op.id, id, op.timestamp, body)?; + Action::CommentEdit { id, body, embeds } => { + thread::edit(&mut self.thread, op.id, id, op.timestamp, body, embeds)?; } Action::CommentRedact { id } => { thread::redact(&mut self.thread, op.id, id)?; @@ -252,10 +257,19 @@ impl store::Transaction { } /// 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, + ) -> Result<(), store::Error> { + let hashed = embeds.iter().map(|e| e.hashed()).collect(); + + self.embed(embeds)?; self.push(Action::CommentEdit { id, body: body.to_string(), + embeds: hashed, }) } @@ -276,10 +290,15 @@ impl store::Transaction { &mut self, body: S, reply_to: CommentId, + embeds: Vec, ) -> Result<(), store::Error> { + let hashed = embeds.iter().map(|e| e.hashed()).collect(); + + self.embed(embeds)?; self.push(Action::Comment { body: body.to_string(), reply_to: Some(reply_to), + embeds: hashed, }) } @@ -307,10 +326,19 @@ impl store::Transaction { //////////////////////////////////////////////////////////////////////////////////////////////// /// Create the issue thread. - fn thread(&mut self, body: S) -> Result<(), store::Error> { + fn thread( + &mut self, + body: S, + embeds: impl IntoIterator, + ) -> Result<(), store::Error> { + let embeds = embeds.into_iter().collect::>(); + let hashed = embeds.iter().map(|e| e.hashed()).collect(); + + self.embed(embeds)?; self.push(Action::Comment { body: body.to_string(), reply_to: None, + embeds: hashed, }) } } @@ -367,6 +395,7 @@ where pub fn edit_description( &mut self, description: impl ToString, + embeds: impl IntoIterator, signer: &G, ) -> Result { let Some((id, _)) = self.thread.comments().next() else { @@ -374,7 +403,7 @@ where }; let id = *id; 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, body: S, reply_to: CommentId, + embeds: impl IntoIterator, signer: &G, ) -> Result { assert!( self.thread.comment(&reply_to).is_some(), "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. @@ -502,10 +534,11 @@ where description: impl ToString, labels: &[Label], assignees: &[Did], + embeds: impl IntoIterator, signer: &G, ) -> Result, Error> { 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.edit(title)?; tx.label(labels.to_owned())?; @@ -573,11 +606,21 @@ pub enum Action { /// Should be the root [`CommentId`] if it's a top-level comment. #[serde(default, skip_serializing_if = "Option::is_none")] reply_to: Option, + /// Embeded content. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + embeds: Vec>, }, /// Edit a comment. #[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>, + }, /// Redact a change. Not all changes can be redacted. #[serde(rename = "comment.redact")] @@ -600,6 +643,7 @@ mod test { use super::*; use crate::cob::{ActorId, Reaction}; + use crate::git::Oid; use crate::test; use crate::test::arbitrary; @@ -611,7 +655,14 @@ mod test { let mut eve_issues = Issues::open(&*t.eve.repo).unwrap(); 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(); let id = *issue_alice.id(); @@ -622,10 +673,10 @@ mod test { let mut issue_bob = bob_issues.get_mut(&id).unwrap(); issue_bob - .comment("Bob's reply", id.into(), &t.bob.signer) + .comment("Bob's reply", id.into(), vec![], &t.bob.signer) .unwrap(); issue_alice - .comment("Alice's reply", id.into(), &t.alice.signer) + .comment("Alice's reply", id.into(), vec![], &t.alice.signer) .unwrap(); assert_eq!(issue_bob.comments().count(), 2); @@ -653,7 +704,7 @@ mod test { t.eve.repo.fetch(&t.alice); 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(); t.bob.repo.fetch(&t.eve); @@ -698,6 +749,7 @@ mod test { "Blah blah blah.", &[], &[assignee], + [], &node.signer, ) .unwrap(); @@ -736,6 +788,7 @@ mod test { "Blah blah blah.", &[], &[assignee], + [], &node.signer, ) .unwrap(); @@ -760,7 +813,14 @@ mod test { let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let mut issues = Issues::open(&*repo).unwrap(); let created = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); let (id, created) = (created.id, created.issue); @@ -779,7 +839,14 @@ mod test { let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); issue @@ -820,6 +887,7 @@ mod test { "Blah blah blah.", &[], &[assignee, assignee_two], + [], &node.signer, ) .unwrap(); @@ -839,7 +907,14 @@ mod test { let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &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 mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); issue - .edit_description("Bob Loblaw law blog", &node.signer) + .edit_description("Bob Loblaw law blog", vec![], &node.signer) .unwrap(); let id = issue.id; @@ -875,7 +957,14 @@ mod test { let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); let (comment, _) = issue.root(); @@ -897,13 +986,24 @@ mod test { let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); let (root, _) = issue.root(); let root = *root; - let c1 = issue.comment("Hi hi hi.", root, &node.signer).unwrap(); - let c2 = issue.comment("Ha ha ha.", root, &node.signer).unwrap(); + let c1 = issue + .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 mut issue = issues.get_mut(&id).unwrap(); @@ -913,10 +1013,14 @@ mod test { assert_eq!(reply1.body(), "Hi hi hi."); assert_eq!(reply2.body(), "Ha ha ha."); - issue.comment("Re: Hi.", c1, &node.signer).unwrap(); - issue.comment("Re: Ha.", c2, &node.signer).unwrap(); - issue.comment("Re: Ha. Ha.", c2, &node.signer).unwrap(); - issue.comment("Re: Ha. Ha. Ha.", c2, &node.signer).unwrap(); + issue.comment("Re: Hi.", c1, vec![], &node.signer).unwrap(); + issue.comment("Re: Ha.", c2, vec![], &node.signer).unwrap(); + issue + .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(); @@ -942,6 +1046,7 @@ mod test { "Blah blah blah.", &[ux_label.clone()], &[], + [], &node.signer, ) .unwrap(); @@ -971,15 +1076,26 @@ mod test { let author = *node.signer.public_key(); let mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); // The root thread op id is always the same. let (c0, _) = issue.root(); let c0 = *c0; - issue.comment("Ho ho ho.", c0, &node.signer).unwrap(); - issue.comment("Ha ha ha.", c0, &node.signer).unwrap(); + issue + .comment("Ho ho ho.", c0, vec![], &node.signer) + .unwrap(); + issue + .comment("Ha ha ha.", c0, vec![], &node.signer) + .unwrap(); let id = issue.id; let issue = issues.get(&id).unwrap().unwrap(); @@ -1017,13 +1133,13 @@ mod test { let mut issues = Issues::open(&*repo).unwrap(); issues - .create("First", "Blah", &[], &[], &node.signer) + .create("First", "Blah", &[], &[], [], &node.signer) .unwrap(); issues - .create("Second", "Blah", &[], &[], &node.signer) + .create("Second", "Blah", &[], &[], [], &node.signer) .unwrap(); issues - .create("Third", "Blah", &[], &[], &node.signer) + .create("Third", "Blah", &[], &[], [], &node.signer) .unwrap(); let issues = issues @@ -1050,6 +1166,7 @@ mod test { "Blah blah blah.\nYah yah yah", &[], &[], + [], &node.signer, ) .unwrap(); @@ -1064,4 +1181,106 @@ mod test { assert_eq!(issue.comments().count(), 1); 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"Hello World!".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"Hello World!".to_vec(), + }; + let embed1_edited = Embed { + name: String::from("example.html"), + content: b"Hello Radicle!".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); + } } diff --git a/radicle/src/cob/legacy/issue.rs b/radicle/src/cob/legacy/issue.rs index abdbb8ff..8358d204 100644 --- a/radicle/src/cob/legacy/issue.rs +++ b/radicle/src/cob/legacy/issue.rs @@ -28,7 +28,7 @@ impl store::FromHistory for Issue { type Error = Error; fn type_name() -> &'static TypeName { - &*issue::TYPENAME + &issue::TYPENAME } fn validate(&self) -> Result<(), Self::Error> { diff --git a/radicle/src/cob/legacy/patch.rs b/radicle/src/cob/legacy/patch.rs index 897298c6..fa885336 100644 --- a/radicle/src/cob/legacy/patch.rs +++ b/radicle/src/cob/legacy/patch.rs @@ -103,7 +103,7 @@ impl store::FromHistory for Patch { type Error = Error; fn type_name() -> &'static TypeName { - &*TYPENAME + &TYPENAME } fn validate(&self) -> Result<(), Self::Error> { @@ -242,6 +242,7 @@ impl store::FromHistory for Patch { comment, timestamp, body, + vec![], )?; } } @@ -277,6 +278,7 @@ impl store::FromHistory for Patch { body, None, Some(location), + vec![], )?; } } diff --git a/radicle/src/cob/patch.rs b/radicle/src/cob/patch.rs index fe022f2c..8abb5b7f 100644 --- a/radicle/src/cob/patch.rs +++ b/radicle/src/cob/patch.rs @@ -449,7 +449,7 @@ impl store::FromHistory for Patch { type Error = Error; fn type_name() -> &'static TypeName { - &*TYPENAME + &TYPENAME } fn validate(&self) -> Result<(), Self::Error> { @@ -626,7 +626,14 @@ impl store::FromHistory for Patch { body, } => { 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 { .. } => { @@ -650,6 +657,7 @@ impl store::FromHistory for Patch { body, reply_to, location, + vec![], )?; } } @@ -739,6 +747,7 @@ impl store::FromHistory for Patch { body, reply_to, None, + vec![], )?; } } @@ -748,7 +757,14 @@ impl store::FromHistory for Patch { body, } => { 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 } => { diff --git a/radicle/src/cob/store.rs b/radicle/src/cob/store.rs index 2c36608d..5a593e64 100644 --- a/radicle/src/cob/store.rs +++ b/radicle/src/cob/store.rs @@ -10,7 +10,9 @@ use serde::{Deserialize, Serialize}; use crate::cob::common::Timestamp; 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::prelude::*; use crate::storage::git as storage; @@ -165,6 +167,7 @@ where object_id: ObjectId, message: &str, actions: impl Into>, + embeds: Vec, signer: &G, ) -> Result { let actions = actions.into(); @@ -180,6 +183,7 @@ where object_id, type_name: T::type_name().clone(), message: message.to_owned(), + embeds, changes, }, )?; @@ -194,6 +198,7 @@ where &self, message: &str, actions: impl Into>, + embeds: Vec, signer: &G, ) -> Result<(ObjectId, T), Error> { let actions = actions.into(); @@ -209,6 +214,7 @@ where type_name: T::type_name().clone(), version: Version::default(), message: message.to_owned(), + embeds, contents, }, )?; @@ -288,6 +294,7 @@ where pub struct Transaction { actor: ActorId, actions: Vec, + embeds: Vec, } impl Transaction { @@ -296,6 +303,7 @@ impl Transaction { Self { actor, actions: Vec::new(), + embeds: Vec::new(), } } @@ -313,15 +321,13 @@ impl Transaction { T::Action: Serialize + Clone, { let actor = *signer.public_key(); - let mut tx = Transaction { - actor, - actions: Vec::new(), - }; + let mut tx = Transaction::new(actor); + operations(&mut tx)?; let actions = NonEmpty::from_vec(tx.actions) .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)) } @@ -333,6 +339,13 @@ impl Transaction { Ok(()) } + /// Embed media into the transaction. + pub fn embed(&mut self, embeds: impl IntoIterator) -> Result<(), Error> { + self.embeds.extend(embeds); + + Ok(()) + } + /// Commit transaction. /// /// Returns an operation that can be applied onto an in-memory state. @@ -353,7 +366,7 @@ impl Transaction { head, object, parents, - } = store.update(id, msg, actions.clone(), signer)?; + } = store.update(id, msg, actions.clone(), self.embeds, signer)?; let id = EntryId::from(head); let author = self.actor; let timestamp = Timestamp::from_secs(object.history().timestamp()); diff --git a/radicle/src/cob/thread.rs b/radicle/src/cob/thread.rs index 32897fa6..bcdf7095 100644 --- a/radicle/src/cob/thread.rs +++ b/radicle/src/cob/thread.rs @@ -7,8 +7,8 @@ use serde::{ser::SerializeStruct, Deserialize, Serialize}; use thiserror::Error; use crate::cob; -use crate::cob::common::{Reaction, Timestamp}; -use crate::cob::{ActorId, EntryId, Op}; +use crate::cob::common::{Reaction, Timestamp, Uri}; +use crate::cob::{ActorId, Embed, EntryId, Op}; use crate::prelude::ReadRepository; /// Type name of a thread, as well as the domain for all thread operations. @@ -49,6 +49,8 @@ pub struct Edit { pub timestamp: Timestamp, /// Edit contents. Replaces previous edits. pub body: String, + /// Edit embed list. + pub embeds: Vec>, } /// A comment on a discussion thread. @@ -72,7 +74,7 @@ impl Serialize for Comment { where 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())?; if let Some(loc) = &self.location { state.serialize_field("location", loc)?; @@ -82,6 +84,7 @@ impl Serialize for Comment { } state.serialize_field("reactions", &self.reactions)?; state.serialize_field("body", self.body())?; + state.serialize_field("embeds", self.embeds())?; state.end() } } @@ -93,9 +96,14 @@ impl Comment { body: String, reply_to: Option, location: Option, + embeds: Vec>, timestamp: Timestamp, ) -> Self { - let edit = Edit { body, timestamp }; + let edit = Edit { + body, + embeds, + timestamp, + }; Self { author, @@ -139,8 +147,12 @@ impl Comment { } /// Add an edit. - pub fn edit(&mut self, body: String, timestamp: Timestamp) { - self.edits.push(Edit { body, timestamp }); + pub fn edit(&mut self, body: String, embeds: Vec>, timestamp: Timestamp) { + self.edits.push(Edit { + body, + embeds, + timestamp, + }); } /// Comment reactions. @@ -152,6 +164,14 @@ impl Comment { pub fn location(&self) -> Option<&L> { self.location.as_ref() } + + /// Return the embedded media. + pub fn embeds(&self) -> &[Embed] { + // 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 PartialOrd for Comment { @@ -283,7 +303,7 @@ impl cob::store::FromHistory for Thread { type Error = Error; fn type_name() -> &'static radicle_cob::TypeName { - &*TYPENAME + &TYPENAME } fn validate(&self) -> Result<(), Self::Error> { @@ -301,10 +321,10 @@ impl cob::store::FromHistory for Thread { for action in op.actions { match action { 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 } => { - edit(self, op.id, id, timestamp, body)?; + edit(self, op.id, id, timestamp, body, vec![])?; } Action::Redact { id } => { redact(self, op.id, id)?; @@ -330,6 +350,7 @@ pub fn comment( body: String, reply_to: Option, location: Option, + embeds: Vec>, ) -> Result<(), Error> { if body.is_empty() { return Err(Error::Comment(id)); @@ -341,7 +362,9 @@ pub fn comment( // underlying store guarantees exactly-once delivery of ops. thread.comments.insert( id, - Some(Comment::new(author, body, reply_to, location, timestamp)), + Some(Comment::new( + author, body, reply_to, location, embeds, timestamp, + )), ); Ok(()) @@ -353,6 +376,7 @@ pub fn edit( comment: EntryId, timestamp: Timestamp, body: String, + embeds: Vec>, ) -> Result<(), Error> { if body.is_empty() { return Err(Error::Edit(id)); @@ -367,7 +391,7 @@ pub fn edit( // that as an error. if let Some(comment) = thread.comments.get_mut(&comment) { if let Some(comment) = comment { - comment.edit(body, timestamp); + comment.edit(body, embeds, timestamp); } } else { return Err(Error::Missing(comment)); diff --git a/radicle/src/lib.rs b/radicle/src/lib.rs index a740ed0e..bb99974c 100644 --- a/radicle/src/lib.rs +++ b/radicle/src/lib.rs @@ -1,5 +1,5 @@ #![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)] pub extern crate radicle_crypto as crypto; diff --git a/radicle/src/storage.rs b/radicle/src/storage.rs index 7cb0fa65..e1d160ba 100644 --- a/radicle/src/storage.rs +++ b/radicle/src/storage.rs @@ -333,6 +333,9 @@ pub trait ReadRepository: Sized { fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path) -> Result, git_ext::Error>; + /// Get a blob in this repository, given its id. + fn blob(&self, oid: Oid) -> Result; + /// Validate all remotes with [`ReadRepository::validate_remote`]. fn validate(&self) -> Result<(), VerifyError> { for (_, remote) in self.remotes()? { diff --git a/radicle/src/storage/git.rs b/radicle/src/storage/git.rs index d35ee44b..1cfeb996 100644 --- a/radicle/src/storage/git.rs +++ b/radicle/src/storage/git.rs @@ -357,14 +357,18 @@ impl ReadRepository for Repository { self.backend.path() } - fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result, git::Error> { + fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path) -> Result, git::Error> { git::ext::Blob::At { - object: oid.into(), + object: commit.into(), path, } .get(&self.backend) } + fn blob(&self, oid: Oid) -> Result { + self.backend.find_blob(oid.into()).map_err(git::Error::from) + } + fn validate_remote(&self, remote: &Remote) -> Result, VerifyError> { // Contains a copy of the signed refs of this remote. let mut signed = BTreeMap::from((*remote.refs).clone()); diff --git a/radicle/src/storage/git/cob.rs b/radicle/src/storage/git/cob.rs index 6b41b731..eb58e635 100644 --- a/radicle/src/storage/git/cob.rs +++ b/radicle/src/storage/git/cob.rs @@ -283,6 +283,10 @@ impl<'a> ReadRepository for DraftStore<'a> { self.repo.blob_at(oid, path) } + fn blob(&self, oid: git_ext::Oid) -> Result { + self.repo.blob(oid) + } + fn reference( &self, remote: &RemoteId, diff --git a/radicle/src/test/storage.rs b/radicle/src/test/storage.rs index 586f391a..26d0b6c9 100644 --- a/radicle/src/test/storage.rs +++ b/radicle/src/test/storage.rs @@ -178,6 +178,10 @@ impl ReadRepository for MockRepository { Ok(true) } + fn blob(&self, _oid: Oid) -> Result { + todo!() + } + fn blob_at<'a>( &'a self, _oid: git_ext::Oid,