diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index f3ef7dfd..ff6a7e31 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -12,8 +12,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tower_http::set_header::SetResponseHeaderLayer; -use radicle::cob::{issue, patch}; -use radicle::cob::{thread, ActorId, Tag}; +use radicle::cob::{issue, patch, thread, ActorId, Tag}; use radicle::identity::Id; use radicle::node::NodeId; use radicle::storage::git::paths; @@ -61,7 +60,10 @@ pub fn router(ctx: Context) -> Router { "/projects/:project/patches", post(patch_create_handler).get(patches_handler), ) - .route("/projects/:project/patches/:id", get(patch_handler)) + .route( + "/projects/:project/patches/:id", + get(patch_handler).patch(patch_update_handler), + ) .with_state(ctx) } @@ -595,6 +597,81 @@ async fn patch_create_handler( Json(json!({ "success": true, "id": patch.id.to_string() })), )) } +/// Update an patch. +/// `PATCH /projects/:project/patches/:id` +async fn patch_update_handler( + State(ctx): State, + AuthBearer(token): AuthBearer, + Path((project, patch_id)): Path<(Id, Oid)>, + Json(action): Json, +) -> impl IntoResponse { + ctx.sessions + .write() + .await + .get(&token) + .ok_or(Error::Auth("Unauthorized"))?; + let storage = &ctx.profile.storage; + let signer = ctx + .profile + .signer() + .map_err(|_| Error::Auth("Unauthorized"))?; + 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, + description, + target, + } => { + patch.edit(title, description, target, &signer)?; + } + patch::Action::Tag { add, remove } => { + patch.tag(add, remove, &signer)?; + } + patch::Action::Revision { + description, + base, + oid, + } => { + patch.update(description, base, oid, &signer)?; + } + patch::Action::Redact { .. } => { + todo!() + } + patch::Action::Review { + revision, + comment, + verdict, + inline, + } => { + patch.review(revision, verdict, comment, inline, &signer)?; + } + patch::Action::Merge { revision, commit } => { + patch.merge(revision, commit, &signer)?; + } + patch::Action::Thread { action, revision } => match action { + thread::Action::Comment { body, reply_to } => { + if let Some(reply_to) = reply_to { + patch.comment(revision, body, Some(reply_to), &signer)?; + } else { + patch.thread(revision, body, &signer)?; + } + } + thread::Action::Edit { .. } => { + todo!(); + } + thread::Action::Redact { .. } => { + todo!(); + } + thread::Action::React { .. } => { + todo!(); + } + }, + }; + + Ok::<_, Error>(Json(json!({ "success": true }))) +} /// Get project patches list. /// `GET /projects/:project/patches` @@ -643,16 +720,12 @@ mod routes { use pretty_assertions::assert_eq; use serde_json::json; - use crate::test::{ - self, get, patch, post, CONTRIBUTOR_ISSUE_ID, CONTRIBUTOR_PUB_KEY, CONTRIBUTOR_RID, HEAD, - INITIAL_COMMIT, ISSUE_COMMENT_ID, ISSUE_DISCUSSION_ID, ISSUE_ID, PARENT, PATCH_ID, RID, - SESSION_ID, TIMESTAMP, - }; + use crate::test::*; #[tokio::test] async fn test_projects_root() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let app = super::router(seed(tmp.path())); let response = get(&app, "/projects").await; assert_eq!(response.status(), StatusCode::OK); @@ -663,7 +736,7 @@ mod routes { "name": "hello-world", "description": "Rad repository for tests", "defaultBranch": "master", - "delegates": ["did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"], + "delegates": [DID], "head": HEAD, "patches": { "proposed": 1, @@ -683,7 +756,7 @@ mod routes { #[tokio::test] async fn test_projects() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let app = super::router(seed(tmp.path())); let response = get(&app, format!("/projects/{RID}")).await; assert_eq!(response.status(), StatusCode::OK); @@ -693,7 +766,7 @@ mod routes { "name": "hello-world", "description": "Rad repository for tests", "defaultBranch": "master", - "delegates": ["did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"], + "delegates": [DID], "head": HEAD, "patches": { "proposed": 1, @@ -712,7 +785,7 @@ mod routes { #[tokio::test] async fn test_projects_commits_root() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let app = super::router(seed(tmp.path())); let response = get(&app, format!("/projects/{RID}/commits")).await; assert_eq!(response.status(), StatusCode::OK); @@ -945,7 +1018,7 @@ mod routes { #[tokio::test] async fn test_projects_commits() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let app = super::router(seed(tmp.path())); let response = get(&app, format!("/projects/{RID}/commits/{HEAD}")).await; assert_eq!(response.status(), StatusCode::OK); @@ -1047,7 +1120,7 @@ mod routes { #[tokio::test] async fn test_projects_tree() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let app = super::router(seed(tmp.path())); let response = get(&app, format!("/projects/{RID}/tree/{HEAD}/")).await; assert_eq!(response.status(), StatusCode::OK); @@ -1132,7 +1205,7 @@ mod routes { #[tokio::test] async fn test_projects_remotes_root() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let app = super::router(seed(tmp.path())); let response = get(&app, format!("/projects/{RID}/remotes")).await; assert_eq!(response.status(), StatusCode::OK); @@ -1153,7 +1226,7 @@ mod routes { #[tokio::test] async fn test_projects_remotes() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let app = super::router(seed(tmp.path())); let response = get( &app, format!("/projects/{RID}/remotes/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"), @@ -1176,7 +1249,7 @@ mod routes { #[tokio::test] async fn test_projects_blob() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let app = super::router(seed(tmp.path())); let response = get(&app, format!("/projects/{RID}/blob/{HEAD}/README")).await; assert_eq!(response.status(), StatusCode::OK); @@ -1208,7 +1281,7 @@ mod routes { #[tokio::test] async fn test_projects_readme() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let app = super::router(seed(tmp.path())); let response = get(&app, format!("/projects/{RID}/readme/{INITIAL_COMMIT}")).await; assert_eq!(response.status(), StatusCode::OK); @@ -1240,7 +1313,7 @@ mod routes { #[tokio::test] async fn test_projects_diff() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let app = super::router(seed(tmp.path())); let response = get( &app, format!("/projects/{RID}/diff/{INITIAL_COMMIT}/{HEAD}"), @@ -1320,7 +1393,7 @@ mod routes { #[tokio::test] async fn test_projects_issues_root() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); + let app = super::router(seed(tmp.path())); let response = get(&app, format!("/projects/{RID}/issues")).await; assert_eq!(response.status(), StatusCode::OK); @@ -1330,7 +1403,7 @@ mod routes { { "id": ISSUE_ID, "author": { - "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + "id": DID }, "title": "Issue #1", "state": { @@ -1341,7 +1414,7 @@ mod routes { { "id": ISSUE_ID, "author": { - "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + "id": DID }, "body": "Change 'hello world' to 'hello everyone'", "reactions": [], @@ -1360,10 +1433,10 @@ mod routes { const CREATED_ISSUE_ID: &str = "b457364fbe2ef0eac69a835a087f60ee13ccb367"; let tmp = tempfile::tempdir().unwrap(); - let ctx = test::contributor(tmp.path()); + let ctx = contributor(tmp.path()); let app = super::router(ctx.to_owned()); - test::create_session(ctx).await; + create_session(ctx).await; let body = serde_json::to_vec(&json!({ "title": "Issue #2", @@ -1398,7 +1471,7 @@ mod routes { json!({ "id": CREATED_ISSUE_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_DID, }, "assignees": [], "title": "Issue #2", @@ -1408,7 +1481,7 @@ mod routes { "discussion": [{ "id": CREATED_ISSUE_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_DID, }, "body": "Change 'hello world' to 'hello everyone'", "reactions": [], @@ -1425,10 +1498,10 @@ mod routes { #[tokio::test] async fn test_projects_issues_comment() { let tmp = tempfile::tempdir().unwrap(); - let ctx = test::contributor(tmp.path()); + let ctx = contributor(tmp.path()); let app = super::router(ctx.to_owned()); - test::create_session(ctx).await; + create_session(ctx).await; let body = serde_json::to_vec(&json!({ "type": "thread", @@ -1461,7 +1534,7 @@ mod routes { json!({ "id": CONTRIBUTOR_ISSUE_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_DID, }, "assignees": [], "title": "Issue #1", @@ -1472,7 +1545,7 @@ mod routes { { "id": ISSUE_DISCUSSION_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_DID, }, "body": "Change 'hello world' to 'hello everyone'", "reactions": [], @@ -1482,7 +1555,7 @@ mod routes { { "id": "9685b141c2e939c3d60f8ca34f8c7bf01a609af1", "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_DID, }, "body": "This is first-level comment", "reactions": [], @@ -1498,10 +1571,10 @@ mod routes { #[tokio::test] async fn test_projects_issues_reply() { let tmp = tempfile::tempdir().unwrap(); - let ctx = test::contributor(tmp.path()); + let ctx = contributor(tmp.path()); let app = super::router(ctx.to_owned()); - test::create_session(ctx).await; + create_session(ctx).await; let body = serde_json::to_vec(&json!({ "type": "thread", @@ -1536,7 +1609,7 @@ mod routes { json!({ "id": CONTRIBUTOR_ISSUE_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_DID, }, "assignees": [], "title": "Issue #1", @@ -1547,7 +1620,7 @@ mod routes { { "id": ISSUE_DISCUSSION_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_DID, }, "body": "Change 'hello world' to 'hello everyone'", "reactions": [], @@ -1557,7 +1630,7 @@ mod routes { { "id": ISSUE_COMMENT_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY, + "id": CONTRIBUTOR_DID, }, "body": "This is a reply to the first comment", "reactions": [], @@ -1573,32 +1646,33 @@ mod routes { #[tokio::test] async fn test_projects_patches() { let tmp = tempfile::tempdir().unwrap(); - let app = super::router(test::seed(tmp.path())); - let response = get(&app, format!("/projects/{RID}/patches")).await; + let ctx = contributor(tmp.path()); + let app = super::router(ctx.to_owned()); + let response = get(&app, format!("/projects/{CONTRIBUTOR_RID}/patches")).await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.json().await, json!([ { - "id": PATCH_ID, + "id": CONTRIBUTOR_PATCH_ID, "author": { - "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + "id": CONTRIBUTOR_DID }, - "title": "A new `hello word`", + "title": "A new `hello world`", "description": "change `hello world` in README to something else", "state": { "status": "proposed" }, "target": "delegates", "tags": [], "revisions": [ { - "id": PATCH_ID, + "id": CONTRIBUTOR_PATCH_ID, "description": "", "base": PARENT, "oid": HEAD, "merges": [], "discussions": [], - "timestamp": 1671125284, + "timestamp": TIMESTAMP, "reviews": [], } ], @@ -1606,31 +1680,35 @@ mod routes { ]) ); - let response = get(&app, format!("/projects/{RID}/patches/{PATCH_ID}")).await; + let response = get( + &app, + format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"), + ) + .await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.json().await, json!( { - "id": PATCH_ID, + "id": CONTRIBUTOR_PATCH_ID, "author": { - "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + "id": CONTRIBUTOR_DID }, - "title": "A new `hello word`", + "title": "A new `hello world`", "description": "change `hello world` in README to something else", "state": { "status": "proposed" }, "target": "delegates", "tags": [], "revisions": [ { - "id": PATCH_ID, + "id": CONTRIBUTOR_PATCH_ID, "description": "", "base": PARENT, "oid": HEAD, "merges": [], "discussions": [], - "timestamp": 1671125284, + "timestamp": TIMESTAMP, "reviews": [], } ], @@ -1644,10 +1722,10 @@ mod routes { const CREATED_PATCH_ID: &str = "f69641cba6d7df2c22844d7f39225b5cda54d363"; let tmp = tempfile::tempdir().unwrap(); - let ctx = test::contributor(tmp.path()); + let ctx = contributor(tmp.path()); let app = super::router(ctx.to_owned()); - test::create_session(ctx).await; + create_session(ctx).await; let body = serde_json::to_vec(&json!({ "title": "Update README", @@ -1690,7 +1768,7 @@ mod routes { { "id": CREATED_PATCH_ID, "author": { - "id": CONTRIBUTOR_PUB_KEY + "id": CONTRIBUTOR_DID }, "title": "Update README", "description": "Do some changes to README", @@ -1705,7 +1783,7 @@ mod routes { "oid": HEAD, "merges": [], "discussions": [], - "timestamp": 1671125284, + "timestamp": TIMESTAMP, "reviews": [], } ], @@ -1713,4 +1791,443 @@ mod routes { ) ); } + + #[tokio::test] + async fn test_projects_patches_tag() { + 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": "tag", + "add": ["bug","design"], + "remove": [] + })) + .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`", + "description": "change `hello world` in README to something else", + "state": { "status": "proposed" }, + "target": "delegates", + "tags": [ + "bug", + "design" + ], + "revisions": [ + { + "id": CONTRIBUTOR_PATCH_ID, + "description": "", + "base": PARENT, + "oid": HEAD, + "merges": [], + "discussions": [], + "timestamp": TIMESTAMP, + "reviews": [], + }, + ], + }) + ); + } + + #[tokio::test] + async fn test_projects_patches_revisions() { + 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", + "description": "This is a new revision", + "base": PARENT, + "oid": HEAD, + })) + .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`", + "description": "change `hello world` in README to something else", + "state": { "status": "proposed" }, + "target": "delegates", + "tags": [], + "revisions": [ + { + "id": CONTRIBUTOR_PATCH_ID, + "description": "", + "base": PARENT, + "oid": HEAD, + "merges": [], + "discussions": [], + "timestamp": TIMESTAMP, + "reviews": [], + }, + { + "id": "bdc0364b51a3346653a795b73332eb792283be37", + "description": "This is a new revision", + "base": PARENT, + "oid": HEAD, + "merges": [], + "discussions": [], + "timestamp": TIMESTAMP, + "reviews": [], + } + ], + }) + ); + } + + #[tokio::test] + async fn test_projects_patches_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": "edit", + "title": "This is a updated title", + "description": "Let's write some description", + "target": "delegates", + })) + .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": "This is a updated title", + "description": "Let's write some description", + "state": { "status": "proposed" }, + "target": "delegates", + "tags": [], + "revisions": [ + { + "id": CONTRIBUTOR_PATCH_ID, + "description": "", + "base": PARENT, + "oid": HEAD, + "merges": [], + "discussions": [], + "timestamp": TIMESTAMP, + "reviews": [], + }, + ], + }) + ); + } + + #[tokio::test] + async fn test_projects_patches_discussions() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = contributor(tmp.path()); + let app = super::router(ctx.to_owned()); + create_session(ctx).await; + let thread_body = serde_json::to_vec(&json!({ + "type": "thread", + "revision": CONTRIBUTOR_PATCH_ID, + "action": { + "type": "comment", + "body": "This is a root level comment" + } + })) + .unwrap(); + let response = patch( + &app, + format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"), + Some(Body::from(thread_body)), + Some(SESSION_ID.to_string()), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + + let reply_body = serde_json::to_vec(&json!({ + "type": "thread", + "revision": CONTRIBUTOR_PATCH_ID, + "action": { + "type": "comment", + "body": "This is a root level comment", + "replyTo": CONTRIBUTOR_COMMENT_1, + } + })) + .unwrap(); + let response = patch( + &app, + format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"), + Some(Body::from(reply_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`", + "description": "change `hello world` in README to something else", + "state": { "status": "proposed" }, + "target": "delegates", + "tags": [], + "revisions": [ + { + "id": CONTRIBUTOR_PATCH_ID, + "description": "", + "base": PARENT, + "oid": HEAD, + "merges": [], + "discussions": [ + { + "id": CONTRIBUTOR_COMMENT_1, + "author": { + "id": CONTRIBUTOR_DID, + }, + "body": "This is a root level comment", + "reactions": [], + "timestamp": TIMESTAMP, + "replyTo": null, + }, + { + "id": CONTRIBUTOR_COMMENT_2, + "author": { + "id": CONTRIBUTOR_DID, + }, + "body": "This is a root level comment", + "reactions": [], + "timestamp": TIMESTAMP, + "replyTo": CONTRIBUTOR_COMMENT_1, + }, + ], + "timestamp": TIMESTAMP, + "reviews": [], + }, + ], + }) + ); + } + + #[tokio::test] + async fn test_projects_patches_reviews() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = contributor(tmp.path()); + let app = super::router(ctx.to_owned()); + create_session(ctx).await; + let thread_body = serde_json::to_vec(&json!({ + "type": "review", + "revision": CONTRIBUTOR_PATCH_ID, + "comment": "A small review", + "verdict": "accept", + "inline": [ + { + "location": { + "blob": HEAD, + "commit": HEAD, + "lines": { + "start": 1, + "end": 3, + }, + }, + "comment": "This is a comment on line 1", + "timestamp": TIMESTAMP, + } + ] + })) + .unwrap(); + let response = patch( + &app, + format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"), + Some(Body::from(thread_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`", + "description": "change `hello world` in README to something else", + "state": { "status": "proposed" }, + "target": "delegates", + "tags": [], + "revisions": [ + { + "id": CONTRIBUTOR_PATCH_ID, + "description": "", + "base": PARENT, + "oid": HEAD, + "merges": [], + "discussions": [], + "timestamp": TIMESTAMP, + "reviews": [ + [ + CONTRIBUTOR_NID, + { + "verdict": "accept", + "comment": "A small review", + "inline": [ + { + "location": { + "blob": HEAD, + "commit": HEAD, + "lines": { + "start": 1, + "end": 3, + }, + }, + "comment": "This is a comment on line 1", + "timestamp": TIMESTAMP, + } + ], + "timestamp": TIMESTAMP, + }, + ], + ], + }, + ], + }) + ); + } + + #[tokio::test] + async fn test_projects_patches_merges() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = contributor(tmp.path()); + let app = super::router(ctx.to_owned()); + create_session(ctx).await; + let thread_body = serde_json::to_vec(&json!({ + "type": "merge", + "revision": CONTRIBUTOR_PATCH_ID, + "commit": PARENT, + })) + .unwrap(); + let response = patch( + &app, + format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"), + Some(Body::from(thread_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`", + "description": "change `hello world` in README to something else", + "state": { "status": "proposed" }, + "target": "delegates", + "tags": [], + "revisions": [ + { + "id": CONTRIBUTOR_PATCH_ID, + "description": "", + "base": PARENT, + "oid": HEAD, + "merges": [ + { + "node": CONTRIBUTOR_NID, + "commit": PARENT, + "timestamp": TIMESTAMP, + }, + ], + "discussions": [], + "timestamp": TIMESTAMP, + "reviews": [], + }, + ], + }) + ); + } } diff --git a/radicle-httpd/src/test.rs b/radicle-httpd/src/test.rs index c144041e..9bf1940e 100644 --- a/radicle-httpd/src/test.rs +++ b/radicle-httpd/src/test.rs @@ -27,15 +27,19 @@ pub const RID: &str = "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp"; pub const HEAD: &str = "e8c676b9e3b42308dc9d218b70faa5408f8e58ca"; pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83"; pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7"; -pub const PATCH_ID: &str = "a6afa179631163169bf9fb0921229317ec3950af"; +pub const DID: &str = "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"; pub const ISSUE_ID: &str = "5ad77fa3f476beed9a26f49b2b3b844e61bef792"; pub const ISSUE_DISCUSSION_ID: &str = "f1dff128a22e8183a23516dd9812e72e80914c92"; pub const ISSUE_COMMENT_ID: &str = "845218041bf9eb8155bfa4aaa8f0c91ce18e5c13"; pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy"; pub const TIMESTAMP: u64 = 1671125284; pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG"; -pub const CONTRIBUTOR_PUB_KEY: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"; +pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"; +pub const CONTRIBUTOR_NID: &str = "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"; pub const CONTRIBUTOR_ISSUE_ID: &str = "f1dff128a22e8183a23516dd9812e72e80914c92"; +pub const CONTRIBUTOR_PATCH_ID: &str = "4b7ea91da1f551bc5ea5d2057125d7ba42d451cb"; +pub const CONTRIBUTOR_COMMENT_1: &str = "8f6e72c20da0e6ee2af004f059ced23d409a285a"; +pub const CONTRIBUTOR_COMMENT_2: &str = "c88ce78b17a705f389e72ec5c80da7360a675e51"; const PASSWORD: &str = "radicle"; @@ -178,7 +182,7 @@ fn seed_with_signer(dir: &Path, profile: radicle::Profile, signer: &G let base = radicle::git::Oid::from_str(PARENT).unwrap(); let _ = patches .create( - "A new `hello word`", + "A new `hello world`", "change `hello world` in README to something else", MergeTarget::Delegates, base, diff --git a/radicle/src/cob/patch.rs b/radicle/src/cob/patch.rs index fe336e7d..522f20bc 100644 --- a/radicle/src/cob/patch.rs +++ b/radicle/src/cob/patch.rs @@ -519,9 +519,10 @@ impl Serialize for Review { where S: serde::ser::Serializer, { - let mut state = serializer.serialize_struct("Review", 3)?; + let mut state = serializer.serialize_struct("Review", 4)?; state.serialize_field("verdict", &self.verdict())?; state.serialize_field("comment", &self.comment())?; + state.serialize_field("inline", &self.inline().collect::>())?; state.serialize_field("timestamp", &self.timestamp())?; state.end() } @@ -560,6 +561,10 @@ impl Review { self.verdict.get().as_ref().copied() } + pub fn inline(&self) -> impl Iterator { + self.inline.iter().map(|m| m.get()) + } + pub fn comment(&self) -> Option<&str> { self.comment.get().as_ref().map(|m| m.get().as_str()) } @@ -721,6 +726,16 @@ impl<'a, 'g> PatchMut<'a, 'g> { self.transaction("Edit", signer, |tx| tx.edit(title, description, target)) } + /// Create a thread on a patch revision. + pub fn thread( + &mut self, + revision: RevisionId, + body: S, + signer: &G, + ) -> Result { + self.transaction("Create thread", signer, |tx| tx.thread(revision, body)) + } + /// Comment on a patch revision. pub fn comment( &mut self,