Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
httpd: Add missing COB actions
Sebastian Martinez committed 2 years ago
commit 78e68336726e390dd549302e321e8d301118ad97
parent 7c842bcc1d392184846cb67082850794f4fb0602
8 files changed +479 -71
modified radicle-cli/src/commands/patch/comment.rs
@@ -27,7 +27,7 @@ pub fn run(
        .ok_or_else(|| anyhow!("Patch revision `{revision_id}` not found"))?;
    let mut patch = patch::PatchMut::new(patch_id, patch, &mut patches);
    let (body, reply_to) = prompt(message, reply_to, &revision, repo)?;
-
    let comment_id = patch.comment(revision_id, body, reply_to, &signer)?;
+
    let comment_id = patch.comment(revision_id, body, reply_to, None, vec![], &signer)?;
    let comment = patch
        .revision(&revision_id)
        .ok_or(anyhow!("error retrieving revision `{revision_id}`"))?
modified radicle-httpd/src/api/json.rs
@@ -247,6 +247,7 @@ struct Comment<'a> {
    #[serde(with = "radicle::serde_ext::localtime::time")]
    timestamp: Timestamp,
    reply_to: Option<CommentId>,
+
    resolved: bool,
}

impl<'a> Comment<'a> {
@@ -260,6 +261,7 @@ impl<'a> Comment<'a> {
            reactions: comment.reactions().collect::<Vec<_>>(),
            timestamp: comment.timestamp(),
            reply_to: comment.reply_to(),
+
            resolved: comment.resolved(),
        }
    }
}
modified radicle-httpd/src/api/v1/projects.rs
@@ -732,11 +732,21 @@ async fn issue_update_handler(
        } => {
            issue.react(id, reaction, active, &signer)?;
        }
-
        issue::Action::CommentEdit { .. } => {
-
            todo!();
+
        issue::Action::CommentEdit { id, body, embeds } => {
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| {
+
                    let content = TryInto::<DataUri>::try_into(&embed.content).ok()?;
+
                    Some(Embed {
+
                        name: embed.name,
+
                        content: content.into(),
+
                    })
+
                })
+
                .collect();
+
            issue.edit_comment(id, body, embeds, &signer)?;
        }
-
        issue::Action::CommentRedact { .. } => {
-
            todo!();
+
        issue::Action::CommentRedact { id } => {
+
            issue.redact_comment(id, &signer)?;
        }
    };

@@ -824,11 +834,26 @@ async fn patch_update_handler(
        patch::Action::Edit { title, target } => {
            patch.edit(title, target, &signer)?;
        }
-
        patch::Action::RevisionEdit {
+
        patch::Action::Label { labels } => {
+
            patch.label(labels, &signer)?;
+
        }
+
        patch::Action::Lifecycle { state } => {
+
            patch.lifecycle(state, &signer)?;
+
        }
+
        patch::Action::Assign { assignees } => {
+
            patch.assign(assignees, &signer)?;
+
        }
+
        patch::Action::Merge { revision, commit } => {
+
            // TODO: We should cleanup the stored copy at least.
+
            let _ = patch.merge(revision, commit, &signer)?;
+
        }
+
        patch::Action::Review {
            revision,
-
            description,
+
            summary,
+
            verdict,
+
            labels,
        } => {
-
            patch.edit_revision(revision, description, &signer)?;
+
            patch.review(revision, verdict, summary, labels, &signer)?;
        }
        patch::Action::ReviewEdit {
            review,
@@ -837,20 +862,45 @@ async fn patch_update_handler(
        } => {
            patch.edit_review(review, summary, verdict, &signer)?;
        }
+
        patch::Action::ReviewRedact { review } => {
+
            patch.redact_review(review, &signer)?;
+
        }
        patch::Action::ReviewComment {
            review,
            body,
            reply_to,
-
            ..
+
            location,
+
            embeds,
        } => {
-
            patch.review_comment(review, body, None, reply_to, &signer)?;
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| {
+
                    let content = TryInto::<DataUri>::try_into(&embed.content).ok()?;
+
                    Some(Embed {
+
                        name: embed.name,
+
                        content: content.into(),
+
                    })
+
                })
+
                .collect();
+
            patch.review_comment(review, body, location, reply_to, embeds, &signer)?;
        }
        patch::Action::ReviewCommentEdit {
            review,
            comment,
            body,
+
            embeds,
        } => {
-
            patch.edit_review_comment(review, comment, body, &signer)?;
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| {
+
                    let content = TryInto::<DataUri>::try_into(&embed.content).ok()?;
+
                    Some(Embed {
+
                        name: embed.name,
+
                        content: content.into(),
+
                    })
+
                })
+
                .collect();
+
            patch.edit_review_comment(review, comment, body, embeds, &signer)?;
        }
        patch::Action::ReviewCommentReact {
            review,
@@ -863,8 +913,11 @@ async fn patch_update_handler(
        patch::Action::ReviewCommentRedact { review, comment } => {
            patch.redact_review_comment(review, comment, &signer)?;
        }
-
        patch::Action::Label { labels } => {
-
            patch.label(labels, &signer)?;
+
        patch::Action::ReviewCommentResolve { review, comment } => {
+
            patch.resolve_review_comment(review, comment, &signer)?;
+
        }
+
        patch::Action::ReviewCommentUnresolve { review, comment } => {
+
            patch.unresolve_review_comment(review, comment, &signer)?;
        }
        patch::Action::Revision {
            description,
@@ -874,35 +927,51 @@ async fn patch_update_handler(
        } => {
            patch.update(description, base, oid, &signer)?;
        }
-
        patch::Action::Lifecycle { state } => {
-
            patch.lifecycle(state, &signer)?;
-
        }
-
        patch::Action::Review {
+
        patch::Action::RevisionEdit {
            revision,
-
            summary,
-
            verdict,
-
            labels,
+
            description,
        } => {
-
            patch.review(revision, verdict, summary, labels, &signer)?;
+
            patch.edit_revision(revision, description, &signer)?;
        }
-
        patch::Action::Merge { revision, commit } => {
-
            // TODO: We should cleanup the stored copy at least.
-
            let _ = patch.merge(revision, commit, &signer)?;
+
        patch::Action::RevisionRedact { revision } => {
+
            patch.redact(revision, &signer)?;
        }
        patch::Action::RevisionComment {
            revision,
            body,
            reply_to,
-
            ..
+
            location,
+
            embeds,
        } => {
-
            patch.comment(revision, body, reply_to, &signer)?;
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| {
+
                    let content = TryInto::<DataUri>::try_into(&embed.content).ok()?;
+
                    Some(Embed {
+
                        name: embed.name,
+
                        content: content.into(),
+
                    })
+
                })
+
                .collect();
+
            patch.comment(revision, body, reply_to, location, embeds, &signer)?;
        }
        patch::Action::RevisionCommentEdit {
            revision,
            comment,
            body,
+
            embeds,
        } => {
-
            patch.comment_edit(revision, comment, body, &signer)?;
+
            let embeds: Vec<Embed> = embeds
+
                .into_iter()
+
                .filter_map(|embed| {
+
                    let content = TryInto::<DataUri>::try_into(&embed.content).ok()?;
+
                    Some(Embed {
+
                        name: embed.name,
+
                        content: content.into(),
+
                    })
+
                })
+
                .collect();
+
            patch.comment_edit(revision, comment, body, embeds, &signer)?;
        }
        patch::Action::RevisionCommentReact {
            revision,
@@ -1937,7 +2006,8 @@ mod routes {
                    "embeds": [],
                    "reactions": [],
                    "timestamp": TIMESTAMP,
-
                    "replyTo": null
+
                    "replyTo": null,
+
                    "resolved": false,
                  }
                ],
                "labels": []
@@ -2017,6 +2087,7 @@ mod routes {
                "reactions": [],
                "timestamp": TIMESTAMP,
                "replyTo": null,
+
                "resolved": false,
              }],
              "labels": [
                  "bug",
@@ -2059,7 +2130,7 @@ mod routes {

        let body = serde_json::to_vec(&json!({
          "type": "comment.react",
-
          "id": "6fe9fdec2ec9f6436f2875dbcbedb95dd215b863",
+
          "id": ISSUE_DISCUSSION_ID,
          "reaction": "🚀",
          "active": true,
        }))
@@ -2072,6 +2143,40 @@ mod routes {
        )
        .await;

+
        let body = serde_json::to_vec(&json!({
+
          "type": "comment.edit",
+
          "id": ISSUE_DISCUSSION_ID,
+
          "body": "EDIT: Change 'hello world' to 'hello anyone'",
+
          "embeds": []
+
        }))
+
        .unwrap();
+

+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{CONTRIBUTOR_ISSUE_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.json().await, json!({ "success": true }));
+

+
        let body = serde_json::to_vec(&json!({
+
          "type": "comment.redact",
+
          "id": "6fe9fdec2ec9f6436f2875dbcbedb95dd215b863",
+
        }))
+
        .unwrap();
+

+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/issues/{CONTRIBUTOR_ISSUE_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.json().await, json!({ "success": true }));
+

        let response = get(
            &app,
            format!("/projects/{CONTRIBUTOR_RID}/issues/{CONTRIBUTOR_ISSUE_ID}"),
@@ -2096,32 +2201,17 @@ mod routes {
                  "author": {
                    "id": CONTRIBUTOR_DID,
                  },
-
                  "body": "Change 'hello world' to 'hello everyone'",
+
                  "body": "EDIT: Change 'hello world' to 'hello anyone'",
                  "embeds": [],
-
                  "reactions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "replyTo": null,
-
                },
-
                {
-
                  "id": "6fe9fdec2ec9f6436f2875dbcbedb95dd215b863",
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                  },
-
                  "body": "This is first-level comment",
-
                  "embeds": [
-
                    {
-
                      "name": "image.jpg",
-
                      "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                    },
-
                  ],
                  "reactions": [
                    [
-
                      "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8",
-
                      "🚀",
+
                    "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8",
+
                    "🚀",
                    ],
                  ],
                  "timestamp": TIMESTAMP,
-
                  "replyTo": CONTRIBUTOR_ISSUE_ID,
+
                  "replyTo": null,
+
                  "resolved": false,
                },
              ],
              "labels": [],
@@ -2185,6 +2275,7 @@ mod routes {
                  "reactions": [],
                  "timestamp": TIMESTAMP,
                  "replyTo": null,
+
                  "resolved": false,
                },
                {
                  "id": ISSUE_COMMENT_ID,
@@ -2196,6 +2287,7 @@ mod routes {
                  "reactions": [],
                  "timestamp": TIMESTAMP,
                  "replyTo": ISSUE_DISCUSSION_ID,
+
                  "resolved": false,
                },
              ],
              "labels": [],
@@ -2371,7 +2463,68 @@ mod routes {
    }

    #[tokio::test]
-
    async fn test_projects_patches_tag() {
+
    async fn test_projects_patches_assign() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        create_session(ctx).await;
+
        let body = serde_json::to_vec(&json!({
+
          "type": "assign",
+
          "assignees": [CONTRIBUTOR_DID]
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": CONTRIBUTOR_PATCH_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
              },
+
              "title": "A new `hello world`",
+
              "state": { "status": "open" },
+
              "target": "delegates",
+
              "labels": [],
+
              "merges": [],
+
              "assignees": [CONTRIBUTOR_DID],
+
              "revisions": [
+
                {
+
                  "id": CONTRIBUTOR_PATCH_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                  },
+
                  "description": "change `hello world` in README to something else",
+
                  "base": PARENT,
+
                  "oid": HEAD,
+
                  "refs": [
+
                    "refs/heads/master",
+
                  ],
+
                  "discussions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "reviews": [],
+
                },
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_projects_patches_label() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = contributor(tmp.path());
        let app = super::router(ctx.to_owned());
@@ -2576,6 +2729,68 @@ mod routes {
    }

    #[tokio::test]
+
    async fn test_projects_patches_revisions_edit() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = contributor(tmp.path());
+
        let app = super::router(ctx.to_owned());
+
        create_session(ctx).await;
+
        let body = serde_json::to_vec(&json!({
+
          "type": "revision.edit",
+
          "revision": CONTRIBUTOR_PATCH_ID,
+
          "description": "Let's change the description a bit",
+
        }))
+
        .unwrap();
+
        let response = patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+

+
        let response = get(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
        )
+
        .await;
+

+
        assert_eq!(
+
            response.json().await,
+
            json!({
+
              "id": CONTRIBUTOR_PATCH_ID,
+
              "author": {
+
                "id": CONTRIBUTOR_DID,
+
              },
+
              "title": "A new `hello world`",
+
              "state": { "status": "open" },
+
              "target": "delegates",
+
              "labels": [],
+
              "merges": [],
+
              "assignees": [],
+
              "revisions": [
+
                {
+
                  "id": CONTRIBUTOR_PATCH_ID,
+
                  "author": {
+
                    "id": CONTRIBUTOR_DID,
+
                  },
+
                  "description": "Let's change the description a bit",
+
                  "base": PARENT,
+
                  "oid": HEAD,
+
                  "refs": [
+
                    "refs/heads/master",
+
                  ],
+
                  "discussions": [],
+
                  "timestamp": TIMESTAMP,
+
                  "reviews": [],
+
                },
+
              ],
+
            })
+
        );
+
    }
+

+
    #[tokio::test]
    async fn test_projects_patches_discussions() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = contributor(tmp.path());
@@ -2584,7 +2799,13 @@ mod routes {
        let thread_body = serde_json::to_vec(&json!({
          "type": "revision.comment",
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "body": "This is a root level comment"
+
          "body": "This is a root level comment",
+
          "embeds": [
+
            {
+
              "name": "image.jpg",
+
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
+
            }
+
          ],
        }))
        .unwrap();
        let response = patch(
@@ -2616,7 +2837,13 @@ mod routes {
          "type": "revision.comment.edit",
          "revision": CONTRIBUTOR_PATCH_ID,
          "comment": CONTRIBUTOR_COMMENT_1,
-
          "body": "EDIT: This is a root level comment"
+
          "body": "EDIT: This is a root level comment",
+
          "embeds": [
+
            {
+
              "name": "image.jpg",
+
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
+
            }
+
          ],
        }))
        .unwrap();
        let response = patch(
@@ -2633,6 +2860,7 @@ mod routes {
          "revision": CONTRIBUTOR_PATCH_ID,
          "body": "This is a root level comment",
          "replyTo": CONTRIBUTOR_COMMENT_1,
+
          "embeds": [],
        }))
        .unwrap();
        let response = patch(
@@ -2683,10 +2911,16 @@ mod routes {
                        "id": CONTRIBUTOR_DID,
                      },
                      "body": "EDIT: This is a root level comment",
-
                      "embeds": [],
+
                      "embeds": [
+
                        {
+
                          "name": "image.jpg",
+
                          "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
+
                        }
+
                      ],
                      "reactions": [["z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8","🚀"]],
                      "timestamp": TIMESTAMP,
                      "replyTo": null,
+
                      "resolved": false,
                    },
                    {
                      "id": CONTRIBUTOR_COMMENT_2,
@@ -2698,6 +2932,7 @@ mod routes {
                      "reactions": [],
                      "timestamp": TIMESTAMP,
                      "replyTo": CONTRIBUTOR_COMMENT_1,
+
                      "resolved": false,
                    },
                  ],
                  "timestamp": TIMESTAMP,
@@ -2734,7 +2969,18 @@ mod routes {
        let review_comment_body = serde_json::to_vec(&json!({
          "type": "review.comment",
          "review": CONTRIBUTOR_PATCH_REVIEW,
-
          "body": "This is a comment on a review"
+
          "body": "This is a comment on a review",
+
          "embeds": [],
+
          "location": {
+
            "path": "README.md",
+
            "new": {
+
              "type": "lines",
+
              "range": {
+
                "start": 2,
+
                "end": 4
+
              }
+
            }
+
          }
        }))
        .unwrap();
        patch(
@@ -2749,7 +2995,8 @@ mod routes {
          "type": "review.comment.edit",
          "review": CONTRIBUTOR_PATCH_REVIEW,
          "comment": CONTRIBUTOR_COMMENT_3,
-
          "body": "EDIT: This is a comment on a review"
+
          "embeds": [],
+
          "body": "EDIT: This is a comment on a review",
        }))
        .unwrap();
        patch(
@@ -2776,6 +3023,20 @@ mod routes {
        )
        .await;

+
        let review_resolve_body = serde_json::to_vec(&json!({
+
          "type": "review.comment.resolve",
+
          "review": CONTRIBUTOR_PATCH_REVIEW,
+
          "comment": CONTRIBUTOR_COMMENT_3,
+
        }))
+
        .unwrap();
+
        patch(
+
            &app,
+
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
+
            Some(Body::from(review_resolve_body)),
+
            Some(SESSION_ID.to_string()),
+
        )
+
        .await;
+

        let response = get(
            &app,
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
@@ -2817,15 +3078,27 @@ mod routes {
                      "verdict": "accept",
                      "summary": "A small review",
                      "comments": [[
-
                        "dd9743bb964ba22399548c86a3c1765020d58f48",
+
                        CONTRIBUTOR_COMMENT_3,
                        {
-
                          "author": "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8",
+
                          "author": CONTRIBUTOR_NID,
+
                          "location": {
+
                            "path": "README.md",
+
                            "old": null,
+
                            "new": {
+
                              "type": "lines",
+
                              "range": {
+
                                "start": 2,
+
                                "end": 4,
+
                              }
+
                            }
+
                          },
                          "reactions": [
                            [
                              "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8",
                              "🚀",
                            ],
                          ],
+
                          "resolved": true,
                          "body": "EDIT: This is a comment on a review",
                        },
                      ]],
modified radicle-httpd/src/lib.rs
@@ -1,5 +1,6 @@
#![allow(clippy::type_complexity)]
#![allow(clippy::too_many_arguments)]
+
#![recursion_limit = "256"]
pub mod error;

use std::collections::HashMap;
modified radicle-httpd/src/test.rs
@@ -45,9 +45,9 @@ pub const CONTRIBUTOR_NID: &str = "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJm
pub const CONTRIBUTOR_ISSUE_ID: &str = "7466975f0bef37b459887824a9655f3e78262522";
pub const CONTRIBUTOR_PATCH_ID: &str = "e651ae5869a2c1ac8ad4f6deae4cc835656ffa25";
pub const CONTRIBUTOR_PATCH_REVIEW: &str = "ee3eeba95f4ec418b3d0714e18e0d1ff605dc0e6";
-
pub const CONTRIBUTOR_COMMENT_1: &str = "d0bb75b2c72ab8b5486d39f6cf5f41f104b63cb1";
-
pub const CONTRIBUTOR_COMMENT_2: &str = "227947fa41420e363752357b2edd54d3ccadf7b3";
-
pub const CONTRIBUTOR_COMMENT_3: &str = "dd9743bb964ba22399548c86a3c1765020d58f48";
+
pub const CONTRIBUTOR_COMMENT_1: &str = "d8ff07edbc8d2229e54e70f2f5bc31614287f0dc";
+
pub const CONTRIBUTOR_COMMENT_2: &str = "f3fca0add53f85bc51a85198efed3273fe13b88e";
+
pub const CONTRIBUTOR_COMMENT_3: &str = "06990ff59faa12463f693dae7a98eb33d75afd2e";

/// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
modified radicle/src/cob/issue.rs
@@ -563,6 +563,19 @@ where
        })
    }

+
    /// Edit a comment.
+
    pub fn edit_comment<G: Signer, S: ToString>(
+
        &mut self,
+
        id: CommentId,
+
        body: S,
+
        embeds: impl IntoIterator<Item = Embed>,
+
        signer: &G,
+
    ) -> Result<EntryId, Error> {
+
        self.transaction("Edit comment", signer, |tx| {
+
            tx.edit_comment(id, body, embeds.into_iter().collect())
+
        })
+
    }
+

    /// Redact a comment.
    pub fn redact_comment<G: Signer>(
        &mut self,
modified radicle/src/cob/patch.rs
@@ -19,7 +19,7 @@ use crate::cob::store::{Cob, CobAction};
use crate::cob::thread;
use crate::cob::thread::Thread;
use crate::cob::thread::{Comment, CommentId, Reactions};
-
use crate::cob::{op, store, ActorId, EntryId, ObjectId, TypeName};
+
use crate::cob::{op, store, ActorId, Embed, EntryId, ObjectId, TypeName, Uri};
use crate::crypto::{PublicKey, Signer};
use crate::git;
use crate::identity;
@@ -176,12 +176,16 @@ pub enum Action {
        /// Should be [`Some`] otherwise.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        reply_to: Option<CommentId>,
+
        /// Embeded content.
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        embeds: Vec<Embed<Uri>>,
    },
    #[serde(rename = "review.comment.edit")]
    ReviewCommentEdit {
        review: ReviewId,
        comment: EntryId,
        body: String,
+
        embeds: Vec<Embed<Uri>>,
    },
    #[serde(rename = "review.comment.redact")]
    ReviewCommentRedact { review: ReviewId, comment: EntryId },
@@ -240,6 +244,9 @@ 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<CommentId>,
+
        /// Embeded content.
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        embeds: Vec<Embed<Uri>>,
    },
    /// Edit a revision comment.
    #[serde(rename = "revision.comment.edit")]
@@ -247,6 +254,7 @@ pub enum Action {
        revision: RevisionId,
        comment: CommentId,
        body: String,
+
        embeds: Vec<Embed<Uri>>,
    },
    /// Redact a revision comment.
    #[serde(rename = "revision.comment.redact")]
@@ -856,6 +864,7 @@ impl Patch {
                review,
                comment,
                body,
+
                embeds,
            } => {
                if let Some(review) = lookup::review_mut(self, &review)? {
                    thread::edit(
@@ -864,7 +873,7 @@ impl Patch {
                        comment,
                        timestamp,
                        body,
-
                        vec![],
+
                        embeds,
                    )?;
                }
            }
@@ -883,6 +892,7 @@ impl Patch {
                body,
                location,
                reply_to,
+
                embeds,
            } => {
                if let Some(review) = lookup::review_mut(self, &review)? {
                    thread::comment(
@@ -893,7 +903,7 @@ impl Patch {
                        body,
                        reply_to,
                        location,
-
                        vec![],
+
                        embeds,
                    )?;
                }
            }
@@ -1008,6 +1018,7 @@ impl Patch {
                revision,
                comment,
                body,
+
                embeds,
            } => {
                if let Some(revision) = lookup::revision_mut(self, &revision)? {
                    thread::edit(
@@ -1016,7 +1027,7 @@ impl Patch {
                        comment,
                        timestamp,
                        body,
-
                        vec![],
+
                        embeds,
                    )?;
                }
            }
@@ -1554,6 +1565,7 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
            body: body.to_string(),
            reply_to: None,
            location: None,
+
            embeds: vec![],
        })
    }

@@ -1563,12 +1575,18 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        revision: RevisionId,
        body: S,
        reply_to: Option<CommentId>,
+
        location: Option<CodeLocation>,
+
        embeds: Vec<Embed>,
    ) -> Result<(), store::Error> {
+
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
+

+
        self.embed(embeds)?;
        self.push(Action::RevisionComment {
            revision,
            body: body.to_string(),
            reply_to,
-
            location: None,
+
            location,
+
            embeds: hashed,
        })
    }

@@ -1578,11 +1596,16 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        revision: RevisionId,
        comment: CommentId,
        body: S,
+
        embeds: Vec<Embed>,
    ) -> Result<(), store::Error> {
+
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
+

+
        self.embed(embeds)?;
        self.push(Action::RevisionCommentEdit {
            revision,
            comment,
            body: body.to_string(),
+
            embeds: hashed,
        })
    }

@@ -1618,26 +1641,54 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        body: S,
        location: Option<CodeLocation>,
        reply_to: Option<CommentId>,
+
        embeds: Vec<Embed>,
    ) -> Result<(), store::Error> {
+
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
+
        self.embed(embeds)?;
+

        self.push(Action::ReviewComment {
            review,
            body: body.to_string(),
            location,
            reply_to,
+
            embeds: hashed,
        })
    }

+
    /// Resolve a review comment.
+
    pub fn review_comment_resolve(
+
        &mut self,
+
        review: ReviewId,
+
        comment: CommentId,
+
    ) -> Result<(), store::Error> {
+
        self.push(Action::ReviewCommentResolve { review, comment })
+
    }
+

+
    /// Unresolve a review comment.
+
    pub fn review_comment_unresolve(
+
        &mut self,
+
        review: ReviewId,
+
        comment: CommentId,
+
    ) -> Result<(), store::Error> {
+
        self.push(Action::ReviewCommentUnresolve { review, comment })
+
    }
+

    /// Edit review comment.
    pub fn edit_review_comment<S: ToString>(
        &mut self,
        review: ReviewId,
        comment: EntryId,
        body: S,
+
        embeds: Vec<Embed>,
    ) -> Result<(), store::Error> {
+
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
+

+
        self.embed(embeds)?;
        self.push(Action::ReviewCommentEdit {
            review,
            comment,
            body: body.to_string(),
+
            embeds: hashed,
        })
    }

@@ -1712,6 +1763,11 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        self.push(Action::Lifecycle { state })
    }

+
    /// Assign a patch.
+
    pub fn assign(&mut self, assignees: BTreeSet<Did>) -> Result<(), store::Error> {
+
        self.push(Action::Assign { assignees })
+
    }
+

    /// Label a patch.
    pub fn label(&mut self, labels: impl IntoIterator<Item = Label>) -> Result<(), store::Error> {
        self.push(Action::Label {
@@ -1828,9 +1884,19 @@ where
        revision: RevisionId,
        body: S,
        reply_to: Option<CommentId>,
+
        location: Option<CodeLocation>,
+
        embeds: impl IntoIterator<Item = Embed>,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Comment", signer, |tx| tx.comment(revision, body, reply_to))
+
        self.transaction("Comment", signer, |tx| {
+
            tx.comment(
+
                revision,
+
                body,
+
                reply_to,
+
                location,
+
                embeds.into_iter().collect(),
+
            )
+
        })
    }

    /// Edit a comment on a patch revision.
@@ -1839,10 +1905,11 @@ where
        revision: RevisionId,
        comment: CommentId,
        body: S,
+
        embeds: impl IntoIterator<Item = Embed>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        self.transaction("Edit comment", signer, |tx| {
-
            tx.comment_edit(revision, comment, body)
+
            tx.comment_edit(revision, comment, body, embeds.into_iter().collect())
        })
    }

@@ -1879,10 +1946,17 @@ where
        body: S,
        location: Option<CodeLocation>,
        reply_to: Option<CommentId>,
+
        embeds: impl IntoIterator<Item = Embed>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        self.transaction("Review comment", signer, |tx| {
-
            tx.review_comment(review, body, location, reply_to)
+
            tx.review_comment(
+
                review,
+
                body,
+
                location,
+
                reply_to,
+
                embeds.into_iter().collect(),
+
            )
        })
    }

@@ -1892,10 +1966,11 @@ where
        review: ReviewId,
        comment: EntryId,
        body: S,
+
        embeds: impl IntoIterator<Item = Embed>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        self.transaction("Edit review comment", signer, |tx| {
-
            tx.edit_review_comment(review, comment, body)
+
            tx.edit_review_comment(review, comment, body, embeds.into_iter().collect())
        })
    }

@@ -1949,6 +2024,30 @@ where
        self.transaction("Redact review", signer, |tx| tx.redact_review(review))
    }

+
    /// Resolve a patch review comment.
+
    pub fn resolve_review_comment<G: Signer>(
+
        &mut self,
+
        review: ReviewId,
+
        comment: CommentId,
+
        signer: &G,
+
    ) -> Result<EntryId, Error> {
+
        self.transaction("Resolve review comment", signer, |tx| {
+
            tx.review_comment_resolve(review, comment)
+
        })
+
    }
+

+
    /// Unresolve a patch review comment.
+
    pub fn unresolve_review_comment<G: Signer>(
+
        &mut self,
+
        review: ReviewId,
+
        comment: CommentId,
+
        signer: &G,
+
    ) -> Result<EntryId, Error> {
+
        self.transaction("Unresolve review comment", signer, |tx| {
+
            tx.review_comment_unresolve(review, comment)
+
        })
+
    }
+

    /// Merge a patch revision.
    pub fn merge<G: Signer>(
        &mut self,
@@ -1985,6 +2084,15 @@ where
        self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state))
    }

+
    /// Assign a patch.
+
    pub fn assign<G: Signer>(
+
        &mut self,
+
        assignees: BTreeSet<Did>,
+
        signer: &G,
+
    ) -> Result<EntryId, Error> {
+
        self.transaction("Assign", signer, |tx| tx.assign(assignees))
+
    }
+

    /// Archive a patch.
    pub fn archive<G: Signer>(&mut self, signer: &G) -> Result<EntryId, Error> {
        self.lifecycle(Lifecycle::Archived, signer)
@@ -2223,6 +2331,7 @@ where
#[allow(clippy::unwrap_used)]
mod test {
    use std::str::FromStr;
+
    use std::vec;

    use pretty_assertions::assert_eq;

@@ -2310,7 +2419,7 @@ mod test {
        let (revision_id, _) = patch.revisions().last().unwrap();
        assert!(
            patch
-
                .comment(revision_id, "patch comment", None, &alice.signer)
+
                .comment(revision_id, "patch comment", None, None, [], &alice.signer)
                .is_ok(),
            "can comment on patch"
        );
@@ -2666,6 +2775,7 @@ mod test {
                "I like these lines of code",
                Some(location.clone()),
                None,
+
                [],
                &alice.signer,
            )
            .unwrap();
modified radicle/src/cob/thread.rs
@@ -83,7 +83,7 @@ impl<T: Serialize> Serialize for Comment<T> {
    where
        S: serde::ser::Serializer,
    {
-
        let mut state = serializer.serialize_struct("Comment", 6)?;
+
        let mut state = serializer.serialize_struct("Comment", 8)?;
        state.serialize_field("author", &self.author())?;
        if let Some(loc) = &self.location {
            state.serialize_field("location", loc)?;
@@ -92,7 +92,11 @@ impl<T: Serialize> Serialize for Comment<T> {
            state.serialize_field("replyTo", &to)?;
        }
        state.serialize_field("reactions", &self.reactions)?;
+
        state.serialize_field("resolved", &self.resolved)?;
        state.serialize_field("body", self.body())?;
+
        if let Some(location) = self.location() {
+
            state.serialize_field("location", &location)?;
+
        }

        let embeds = self.embeds();
        if !embeds.is_empty() {
@@ -179,6 +183,11 @@ impl<L> Comment<L> {
        self.location.as_ref()
    }

+
    /// Get comment resolution status.
+
    pub fn resolved(&self) -> bool {
+
        self.resolved
+
    }
+

    /// Return the embedded media.
    pub fn embeds(&self) -> &[Embed<Uri>] {
        // SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]