Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
cob: Introduce `xyz.radicle.id` COB
cloudhead committed 2 years ago
commit 472f38bacfe3d53aae598f69e4d59ce77a60d4d9
parent 120c109d0d42d8694bc8442833122a4b4cb09fa7
6 files changed +100 -121
modified radicle-httpd/src/api.rs
@@ -17,7 +17,7 @@ use tower_http::cors::{self, CorsLayer};

use radicle::cob::patch;
use radicle::cob::{issue, Uri};
-
use radicle::identity::Id;
+
use radicle::identity::{DocAt, Id};
use radicle::node::routing::Store;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::Profile;
@@ -54,7 +54,7 @@ impl Context {
        let storage = &self.profile.storage;
        let repo = storage.repository(id)?;
        let (_, head) = repo.head()?;
-
        let doc = repo.identity_doc()?.1.verified()?;
+
        let DocAt { doc, .. } = repo.identity_doc()?;
        let payload = doc.project()?;
        let delegates = doc.delegates;
        let issues = issue::Issues::open(&repo)?.counts()?;
modified radicle-httpd/src/api/error.rs
@@ -42,9 +42,9 @@ pub enum Error {
    #[error(transparent)]
    CobStore(#[from] radicle::cob::store::Error),

-
    /// Identity error.
+
    /// Repository error.
    #[error(transparent)]
-
    Identity(#[from] radicle::identity::IdentityError),
+
    Repository(#[from] radicle::storage::RepositoryError),

    /// Project doc error.
    #[error(transparent)]
@@ -115,10 +115,7 @@ impl IntoResponse for Error {
                tracing::error!("Error: {message}");

                if cfg!(debug_assertions) {
-
                    (
-
                        StatusCode::INTERNAL_SERVER_ERROR,
-
                        Some(format!("{other:?}")),
-
                    )
+
                    (StatusCode::INTERNAL_SERVER_ERROR, Some(other.to_string()))
                } else {
                    (StatusCode::INTERNAL_SERVER_ERROR, None)
                }
modified radicle-httpd/src/api/v1/delegates.rs
@@ -5,7 +5,7 @@ use axum::{Json, Router};

use radicle::cob::issue::Issues;
use radicle::cob::patch::Patches;
-
use radicle::identity::Did;
+
use radicle::identity::{Did, DocAt};
use radicle::node::routing::Store;
use radicle::storage::{ReadRepository, ReadStorage};

@@ -42,12 +42,10 @@ async fn delegates_projects_handler(
        .filter_map(|id| {
            let Ok(repo) = storage.repository(id) else { return None };
            let Ok((_, head)) = repo.head() else { return None };
-
            let Ok((_, doc)) = repo.identity_doc() else { return None };
-
            let Ok(doc) = doc.verified() else { return None };
+
            let Ok(DocAt { doc, .. }) = repo.identity_doc() else { return None };
            let Ok(payload) = doc.project() else { return None };

-
            let delegates = doc.delegates;
-
            if !delegates.iter().any(|d| *d == delegate) {
+
            if !doc.delegates.iter().any(|d| *d == delegate) {
                return None;
            }

@@ -56,6 +54,7 @@ async fn delegates_projects_handler(
            let Ok(patches) = Patches::open(&repo) else { return None };
            let Ok(patches) = patches.counts() else { return None };

+
            let delegates = doc.delegates;
            let trackings = routing.count(&id).unwrap_or_default();

            Some(Info {
modified radicle-httpd/src/api/v1/projects.rs
@@ -14,7 +14,7 @@ use serde_json::{json, Value};
use tower_http::set_header::SetResponseHeaderLayer;

use radicle::cob::{issue, patch, Embed, Label, Uri};
-
use radicle::identity::{Did, Id};
+
use radicle::identity::{Did, DocAt, Id};
use radicle::node::routing::Store;
use radicle::node::AliasStore;
use radicle::node::NodeId;
@@ -89,8 +89,7 @@ async fn project_root_handler(
        .filter_map(|id| {
            let Ok(repo) = storage.repository(id) else { return None };
            let Ok((_, head)) = repo.head() else { return None };
-
            let Ok((_, doc)) = repo.identity_doc() else { return None };
-
            let Ok(doc) = doc.verified() else { return None };
+
            let Ok(DocAt { doc, .. }) = repo.identity_doc() else { return None };
            let Ok(payload) = doc.project() else { return None };
            let Ok(issues) = issue::Issues::open(&repo) else { return None };
            let Ok(issues) = issues.counts() else { return None };
@@ -686,24 +685,16 @@ async fn issue_update_handler(
    api::auth::validate(&ctx, &token).await?;

    let storage = &ctx.profile.storage;
-
    let signer = ctx.profile.signer().unwrap();
+
    let signer = ctx.profile.signer()?;
    let repo = storage.repository(project)?;
    let mut issues = issue::Issues::open(&repo)?;
    let mut issue = issues.get_mut(&issue_id.into())?;

-
    match action {
-
        issue::Action::Assign { assignees } => {
-
            issue.assign(assignees, &signer)?;
-
        }
-
        issue::Action::Lifecycle { state } => {
-
            issue.lifecycle(state, &signer)?;
-
        }
-
        issue::Action::Label { labels } => {
-
            issue.label(labels, &signer)?;
-
        }
-
        issue::Action::Edit { title } => {
-
            issue.edit(title, &signer)?;
-
        }
+
    let id = match action {
+
        issue::Action::Assign { assignees } => issue.assign(assignees, &signer)?,
+
        issue::Action::Lifecycle { state } => issue.lifecycle(state, &signer)?,
+
        issue::Action::Label { labels } => issue.label(labels, &signer)?,
+
        issue::Action::Edit { title } => issue.edit(title, &signer)?,
        issue::Action::Comment {
            body,
            reply_to,
@@ -720,7 +711,7 @@ async fn issue_update_handler(
                })
                .collect();
            if let Some(to) = reply_to {
-
                issue.comment(body, to, embeds, &signer)?;
+
                issue.comment(body, to, embeds, &signer)?
            } else {
                return Err(Error::BadRequest("`replyTo` missing".to_owned()));
            }
@@ -729,9 +720,7 @@ async fn issue_update_handler(
            id,
            reaction,
            active,
-
        } => {
-
            issue.react(id, reaction, active, &signer)?;
-
        }
+
        } => issue.react(id, reaction, active, &signer)?,
        issue::Action::CommentEdit { id, body, embeds } => {
            let embeds: Vec<Embed> = embeds
                .into_iter()
@@ -743,14 +732,12 @@ async fn issue_update_handler(
                    })
                })
                .collect();
-
            issue.edit_comment(id, body, embeds, &signer)?;
-
        }
-
        issue::Action::CommentRedact { id } => {
-
            issue.redact_comment(id, &signer)?;
+
            issue.edit_comment(id, body, embeds, &signer)?
        }
+
        issue::Action::CommentRedact { id } => issue.redact_comment(id, &signer)?,
    };

-
    Ok::<_, Error>(Json(json!({ "success": true })))
+
    Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
}

/// Get project issue.
@@ -830,41 +817,27 @@ async fn patch_update_handler(
    let repo = storage.repository(project)?;
    let mut patches = patch::Patches::open(&repo)?;
    let mut patch = patches.get_mut(&patch_id.into())?;
-
    match action {
-
        patch::Action::Edit { title, target } => {
-
            patch.edit(title, target, &signer)?;
-
        }
-
        patch::Action::Label { labels } => {
-
            patch.label(labels, &signer)?;
-
        }
-
        patch::Action::Lifecycle { state } => {
-
            patch.lifecycle(state, &signer)?;
-
        }
-
        patch::Action::Assign { assignees } => {
-
            patch.assign(assignees, &signer)?;
-
        }
+
    let id = match action {
+
        patch::Action::Edit { title, target } => patch.edit(title, target, &signer)?,
+
        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.merge(revision, commit, &signer)?.entry
        }
        patch::Action::Review {
            revision,
            summary,
            verdict,
            labels,
-
        } => {
-
            patch.review(revision, verdict, summary, labels, &signer)?;
-
        }
+
        } => *patch.review(revision, verdict, summary, labels, &signer)?,
        patch::Action::ReviewEdit {
            review,
            summary,
            verdict,
-
        } => {
-
            patch.edit_review(review, summary, verdict, &signer)?;
-
        }
-
        patch::Action::ReviewRedact { review } => {
-
            patch.redact_review(review, &signer)?;
-
        }
+
        } => patch.edit_review(review, summary, verdict, &signer)?,
+
        patch::Action::ReviewRedact { review } => patch.redact_review(review, &signer)?,
        patch::Action::ReviewComment {
            review,
            body,
@@ -882,7 +855,7 @@ async fn patch_update_handler(
                    })
                })
                .collect();
-
            patch.review_comment(review, body, location, reply_to, embeds, &signer)?;
+
            patch.review_comment(review, body, location, reply_to, embeds, &signer)?
        }
        patch::Action::ReviewCommentEdit {
            review,
@@ -900,42 +873,34 @@ async fn patch_update_handler(
                    })
                })
                .collect();
-
            patch.edit_review_comment(review, comment, body, embeds, &signer)?;
+
            patch.edit_review_comment(review, comment, body, embeds, &signer)?
        }
        patch::Action::ReviewCommentReact {
            review,
            comment,
            reaction,
            active,
-
        } => {
-
            patch.react_review_comment(review, comment, reaction, active, &signer)?;
-
        }
+
        } => patch.react_review_comment(review, comment, reaction, active, &signer)?,
        patch::Action::ReviewCommentRedact { review, comment } => {
-
            patch.redact_review_comment(review, comment, &signer)?;
+
            patch.redact_review_comment(review, comment, &signer)?
        }
        patch::Action::ReviewCommentResolve { review, comment } => {
-
            patch.resolve_review_comment(review, comment, &signer)?;
+
            patch.resolve_review_comment(review, comment, &signer)?
        }
        patch::Action::ReviewCommentUnresolve { review, comment } => {
-
            patch.unresolve_review_comment(review, comment, &signer)?;
+
            patch.unresolve_review_comment(review, comment, &signer)?
        }
        patch::Action::Revision {
            description,
            base,
            oid,
            ..
-
        } => {
-
            patch.update(description, base, oid, &signer)?;
-
        }
+
        } => patch.update(description, base, oid, &signer)?.into(),
        patch::Action::RevisionEdit {
            revision,
            description,
-
        } => {
-
            patch.edit_revision(revision, description, &signer)?;
-
        }
-
        patch::Action::RevisionRedact { revision } => {
-
            patch.redact(revision, &signer)?;
-
        }
+
        } => patch.edit_revision(revision, description, &signer)?,
+
        patch::Action::RevisionRedact { revision } => patch.redact(revision, &signer)?,
        patch::Action::RevisionComment {
            revision,
            body,
@@ -953,7 +918,7 @@ async fn patch_update_handler(
                    })
                })
                .collect();
-
            patch.comment(revision, body, reply_to, location, embeds, &signer)?;
+
            patch.comment(revision, body, reply_to, location, embeds, &signer)?
        }
        patch::Action::RevisionCommentEdit {
            revision,
@@ -971,25 +936,23 @@ async fn patch_update_handler(
                    })
                })
                .collect();
-
            patch.comment_edit(revision, comment, body, embeds, &signer)?;
+
            patch.comment_edit(revision, comment, body, embeds, &signer)?
        }
        patch::Action::RevisionCommentReact {
            revision,
            comment,
            reaction,
            active,
-
        } => {
-
            patch.comment_react(revision, comment, reaction, active, &signer)?;
-
        }
+
        } => patch.comment_react(revision, comment, reaction, active, &signer)?,
        patch::Action::RevisionCommentRedact { revision, comment } => {
-
            patch.comment_redact(revision, comment, &signer)?;
+
            patch.comment_redact(revision, comment, &signer)?
        }
        _ => {
            todo!();
        }
    };

-
    Ok::<_, Error>(Json(json!({ "success": true })))
+
    Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
}

/// Get project patches list.
@@ -2058,7 +2021,7 @@ mod routes {

    #[tokio::test]
    async fn test_projects_issues_create() {
-
        const CREATED_ISSUE_ID: &str = "e712eb0b5874d5256022fb620f26caf847d96723";
+
        const CREATED_ISSUE_ID: &str = "8b42657072f6192cba9e08561582576a975656cd";

        let tmp = tempfile::tempdir().unwrap();
        let ctx = contributor(tmp.path());
@@ -2166,7 +2129,7 @@ mod routes {
        .await;

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

        let body = serde_json::to_vec(&json!({
          "type": "comment.react",
@@ -2199,11 +2162,11 @@ mod routes {
        )
        .await;

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

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

@@ -2215,7 +2178,7 @@ mod routes {
        )
        .await;

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

        let response = get(
            &app,
@@ -2284,7 +2247,7 @@ mod routes {
        .await;

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

        let response = get(
            &app,
@@ -2423,7 +2386,7 @@ mod routes {

    #[tokio::test]
    async fn test_projects_create_patches() {
-
        const CREATED_PATCH_ID: &str = "9cffd66099cceb0439a0f67c4aa99bde5e868eaa";
+
        const CREATED_PATCH_ID: &str = "beaed2e1d3b9b01ef10326a9a1c951799ba5fb25";

        let tmp = tempfile::tempdir().unwrap();
        let ctx = contributor(tmp.path());
@@ -2686,7 +2649,7 @@ mod routes {
                  "reviews": [],
                },
                {
-
                  "id": "b1f68feacb7040b089a77c1a0bff60a0411e6c1e",
+
                  "id": "341ba93c6db54e5891fbd3be4a4f64f4715681fa",
                  "author": {
                    "id": CONTRIBUTOR_DID,
                  },
@@ -2857,10 +2820,12 @@ mod routes {
        .await;

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

+
        let comment_id = response.id().await.to_string();
        let comment_react_body = serde_json::to_vec(&json!({
          "type": "revision.comment.react",
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "comment": CONTRIBUTOR_COMMENT_1,
+
          "comment": comment_id,
          "reaction": "🚀",
          "active": true
        }))
@@ -2876,7 +2841,7 @@ mod routes {
        let comment_edit = serde_json::to_vec(&json!({
          "type": "revision.comment.edit",
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "comment": CONTRIBUTOR_COMMENT_1,
+
          "comment": comment_id,
          "body": "EDIT: This is a root level comment",
          "embeds": [
            {
@@ -2899,7 +2864,7 @@ mod routes {
          "type": "revision.comment",
          "revision": CONTRIBUTOR_PATCH_ID,
          "body": "This is a root level comment",
-
          "replyTo": CONTRIBUTOR_COMMENT_1,
+
          "replyTo": comment_id,
          "embeds": [],
        }))
        .unwrap();
@@ -2912,6 +2877,7 @@ mod routes {
        .await;

        assert_eq!(response.status(), StatusCode::OK);
+
        let comment_id_2 = response.id().await.to_string();

        let response = get(
            &app,
@@ -2946,7 +2912,7 @@ mod routes {
                  ],
                  "discussions": [
                    {
-
                      "id": CONTRIBUTOR_COMMENT_1,
+
                      "id": comment_id,
                      "author": {
                        "id": CONTRIBUTOR_DID,
                      },
@@ -2963,7 +2929,7 @@ mod routes {
                      "resolved": false,
                    },
                    {
-
                      "id": CONTRIBUTOR_COMMENT_2,
+
                      "id": comment_id_2,
                      "author": {
                        "id": CONTRIBUTOR_DID,
                      },
@@ -2971,7 +2937,7 @@ mod routes {
                      "embeds": [],
                      "reactions": [],
                      "timestamp": TIMESTAMP,
-
                      "replyTo": CONTRIBUTOR_COMMENT_1,
+
                      "replyTo": comment_id,
                      "resolved": false,
                    },
                  ],
@@ -3006,9 +2972,10 @@ mod routes {

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

+
        let review_id = response.id().await.to_string();
        let review_comment_body = serde_json::to_vec(&json!({
          "type": "review.comment",
-
          "review": CONTRIBUTOR_PATCH_REVIEW,
+
          "review": review_id,
          "body": "This is a comment on a review",
          "embeds": [],
          "location": {
@@ -3023,7 +2990,7 @@ mod routes {
          }
        }))
        .unwrap();
-
        patch(
+
        let response = patch(
            &app,
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
            Some(Body::from(review_comment_body)),
@@ -3031,10 +2998,11 @@ mod routes {
        )
        .await;

+
        let comment_id = response.id().await.to_string();
        let review_comment_edit_body = serde_json::to_vec(&json!({
          "type": "review.comment.edit",
-
          "review": CONTRIBUTOR_PATCH_REVIEW,
-
          "comment": CONTRIBUTOR_COMMENT_3,
+
          "review": review_id,
+
          "comment": comment_id,
          "embeds": [],
          "body": "EDIT: This is a comment on a review",
        }))
@@ -3049,8 +3017,8 @@ mod routes {

        let review_react_body = serde_json::to_vec(&json!({
          "type": "review.comment.react",
-
          "review": CONTRIBUTOR_PATCH_REVIEW,
-
          "comment": CONTRIBUTOR_COMMENT_3,
+
          "review": review_id,
+
          "comment": comment_id,
          "reaction": "🚀",
          "active": true
        }))
@@ -3065,8 +3033,8 @@ mod routes {

        let review_resolve_body = serde_json::to_vec(&json!({
          "type": "review.comment.resolve",
-
          "review": CONTRIBUTOR_PATCH_REVIEW,
-
          "comment": CONTRIBUTOR_COMMENT_3,
+
          "review": review_id,
+
          "comment": comment_id,
        }))
        .unwrap();
        patch(
@@ -3118,7 +3086,7 @@ mod routes {
                      "verdict": "accept",
                      "summary": "A small review",
                      "comments": [[
-
                        CONTRIBUTOR_COMMENT_3,
+
                        comment_id,
                        {
                          "author": CONTRIBUTOR_NID,
                          "location": {
modified radicle-httpd/src/lib.rs
@@ -143,6 +143,7 @@ pub mod logger {
    pub fn subscriber() -> impl tracing::Subscriber {
        tracing_subscriber::FmtSubscriber::builder()
            .with_target(false)
+
            .with_max_level(tracing::Level::DEBUG)
            .finish()
    }
}
modified radicle-httpd/src/test.rs
@@ -34,20 +34,16 @@ pub const HEAD: &str = "e8c676b9e3b42308dc9d218b70faa5408f8e58ca";
pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83";
pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
pub const DID: &str = "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
-
pub const ISSUE_ID: &str = "0b0b8ca3b75e109971f87d92c1a6c930e87484c6";
-
pub const ISSUE_DISCUSSION_ID: &str = "7466975f0bef37b459887824a9655f3e78262522";
-
pub const ISSUE_COMMENT_ID: &str = "24ee306c508cd731a8427612dbdd826209096f99";
+
pub const ISSUE_ID: &str = "4f98396a1ac987af59ec069de9b80d9917b27050";
+
pub const ISSUE_DISCUSSION_ID: &str = "ceafc6629ec8dc0a17644fb5a66726aaafc3ed1c";
+
pub const ISSUE_COMMENT_ID: &str = "59d35e164a21502bc91ad3391ce49baa32ea6a74";
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
pub const TIMESTAMP: u64 = 1671125284;
pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG";
pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
pub const CONTRIBUTOR_NID: &str = "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
-
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 = "d8ff07edbc8d2229e54e70f2f5bc31614287f0dc";
-
pub const CONTRIBUTOR_COMMENT_2: &str = "f3fca0add53f85bc51a85198efed3273fe13b88e";
-
pub const CONTRIBUTOR_COMMENT_3: &str = "06990ff59faa12463f693dae7a98eb33d75afd2e";
+
pub const CONTRIBUTOR_ISSUE_ID: &str = "ceafc6629ec8dc0a17644fb5a66726aaafc3ed1c";
+
pub const CONTRIBUTOR_PATCH_ID: &str = "4ff2ec53a2d165da7f54705023e847d4f9230bc3";

/// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
@@ -96,6 +92,8 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
    let routing_db = dir.join("radicle").join("node").join("routing.db");
    let addresses_db = dir.join("radicle").join("node").join("addresses.db");

+
    crate::logger::init().ok();
+

    TrackingStore::Config::open(tracking_db).unwrap();
    RoutingStore::Table::open(routing_db).unwrap();
    AddressStore::Book::open(addresses_db).unwrap();
@@ -191,7 +189,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
    let storage = &profile.storage;
    let repo = storage.repository(id).unwrap();
    let mut issues = Issues::open(&repo).unwrap();
-
    let _ = issues
+
    let issue = issues
        .create(
            "Issue #1".to_string(),
            "Change 'hello world' to 'hello everyone'".to_string(),
@@ -201,12 +199,13 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
            signer,
        )
        .unwrap();
+
    tracing::debug!(target: "test", "Contributor issue: {}", issue.id());

    // eq. rad patch open
    let mut patches = Patches::open(&repo).unwrap();
    let oid = radicle::git::Oid::from_str(HEAD).unwrap();
    let base = radicle::git::Oid::from_str(PARENT).unwrap();
-
    let _ = patches
+
    let patch = patches
        .create(
            "A new `hello world`",
            "change `hello world` in README to something else",
@@ -217,6 +216,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
            signer,
        )
        .unwrap();
+
    tracing::debug!(target: "test", "Contributor patch: {}", patch.id());

    let options = crate::Options {
        aliases: std::collections::HashMap::new(),
@@ -318,10 +318,24 @@ pub struct Response(axum::response::Response);

impl Response {
    pub async fn json(self) -> Value {
-
        let body = hyper::body::to_bytes(self.0.into_body()).await.unwrap();
+
        let body = self.body().await;
        serde_json::from_slice(&body).unwrap()
    }

+
    pub async fn id(self) -> radicle::git::Oid {
+
        let json = self.json().await;
+
        let string = json["id"].as_str().unwrap();
+

+
        radicle::git::Oid::from_str(string).unwrap()
+
    }
+

+
    pub async fn success(self) -> bool {
+
        let json = self.json().await;
+
        let success = json["success"].as_bool();
+

+
        success.unwrap_or(false)
+
    }
+

    pub fn status(&self) -> axum::http::StatusCode {
        self.0.status()
    }