diff --git a/radicle-httpd/src/api/error.rs b/radicle-httpd/src/api/error.rs index b37f802c..5608a649 100644 --- a/radicle-httpd/src/api/error.rs +++ b/radicle-httpd/src/api/error.rs @@ -34,6 +34,10 @@ pub enum Error { #[error(transparent)] CobIssue(#[from] radicle::cob::issue::Error), + /// Cob patch error. + #[error(transparent)] + CobPatch(#[from] radicle::cob::patch::Error), + /// Cob store error. #[error(transparent)] CobStore(#[from] radicle::cob::store::Error), diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index 6986f76f..53d925bd 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -12,13 +12,12 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tower_http::set_header::SetResponseHeaderLayer; -use radicle::cob::issue::{Action, Issues}; -use radicle::cob::patch::Patches; +use radicle::cob::{issue, patch}; use radicle::cob::{thread, ActorId, Tag}; use radicle::identity::Id; use radicle::node::NodeId; use radicle::storage::git::paths; -use radicle::storage::{ReadRepository, ReadStorage}; +use radicle::storage::{ReadRepository, ReadStorage, WriteRepository}; use radicle_surf::{Glob, Oid, Repository}; use crate::api::error::Error; @@ -57,7 +56,10 @@ pub fn router(ctx: Context) -> Router { "/projects/:project/issues/:id", patch(issue_update_handler).get(issue_handler), ) - .route("/projects/:project/patches", get(patches_handler)) + .route( + "/projects/:project/patches", + post(patch_create_handler).get(patches_handler), + ) .route("/projects/:project/patches/:id", get(patch_handler)) .with_state(ctx) } @@ -81,9 +83,9 @@ async fn project_root_handler( let Ok((_, doc)) = repo.identity_doc() else { return None }; let Ok(doc) = doc.verified() else { return None }; let Ok(payload) = doc.project() else { return None }; - let Ok(issues) = Issues::open(ctx.profile.public_key, &repo) else { return None }; + let Ok(issues) = issue::Issues::open(ctx.profile.public_key, &repo) else { return None }; let Ok(issues) = issues.counts() else { return None }; - let Ok(patches) = Patches::open(ctx.profile.public_key, &repo) else { return None }; + let Ok(patches) = patch::Patches::open(ctx.profile.public_key, &repo) else { return None }; let Ok(patches) = patches.counts() else { return None }; let delegates = doc.delegates; @@ -393,7 +395,7 @@ async fn issues_handler( let per_page = per_page.unwrap_or(10); let storage = &ctx.profile.storage; let repo = storage.repository(project)?; - let issues = Issues::open(ctx.profile.public_key, &repo)?; + let issues = issue::Issues::open(ctx.profile.public_key, &repo)?; let mut issues: Vec<_> = issues.all()?.filter_map(|r| r.ok()).collect::>(); issues.sort_by(|(_, a, _), (_, b, _)| b.timestamp().cmp(&a.timestamp())); let issues = issues @@ -430,7 +432,7 @@ async fn issue_create_handler( .signer() .map_err(|_| Error::Auth("Unauthorized"))?; let repo = storage.repository(project)?; - let mut issues = Issues::open(ctx.profile.public_key, &repo)?; + let mut issues = issue::Issues::open(ctx.profile.public_key, &repo)?; let issue = issues .create( issue.title, @@ -453,7 +455,7 @@ async fn issue_update_handler( State(ctx): State, AuthBearer(token): AuthBearer, Path((project, issue_id)): Path<(Id, Oid)>, - Json(action): Json, + Json(action): Json, ) -> impl IntoResponse { ctx.sessions .write() @@ -464,24 +466,24 @@ async fn issue_update_handler( let storage = &ctx.profile.storage; let signer = ctx.profile.signer().unwrap(); let repo = storage.repository(project)?; - let mut issues = Issues::open(ctx.profile.public_key, &repo)?; + let mut issues = issue::Issues::open(ctx.profile.public_key, &repo)?; let mut issue = issues.get_mut(&issue_id.into())?; match action { - Action::Assign { add, remove } => { + issue::Action::Assign { add, remove } => { issue.assign(add, &signer)?; issue.unassign(remove, &signer)?; } - Action::Lifecycle { state } => { + issue::Action::Lifecycle { state } => { issue.lifecycle(state, &signer)?; } - Action::Tag { add, remove } => { + issue::Action::Tag { add, remove } => { issue.tag(add, remove, &signer)?; } - Action::Edit { title } => { + issue::Action::Edit { title } => { issue.edit(title, &signer)?; } - Action::Thread { action } => match action { + issue::Action::Thread { action } => match action { thread::Action::Comment { body, reply_to } => { if let Some(reply_to) = reply_to { issue.comment(body, reply_to, &signer)?; @@ -512,13 +514,62 @@ async fn issue_handler( ) -> impl IntoResponse { let storage = &ctx.profile.storage; let repo = storage.repository(project)?; - let issue = Issues::open(ctx.profile.public_key, &repo)? + let issue = issue::Issues::open(ctx.profile.public_key, &repo)? .get(&issue_id.into())? .ok_or(Error::NotFound)?; Ok::<_, Error>(Json(api::json::issue(issue_id.into(), issue))) } +#[derive(Deserialize, Serialize)] +pub struct PatchCreate { + pub title: String, + pub description: String, + pub target: Oid, + pub oid: Oid, + pub tags: Vec, +} + +/// Create a new patch. +/// `POST /projects/:project/patches` +async fn patch_create_handler( + State(ctx): State, + AuthBearer(token): AuthBearer, + Path(project): Path, + Json(patch): Json, +) -> impl IntoResponse { + ctx.sessions + .read() + .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(ctx.profile.public_key, &repo)?; + let base_oid = repo.raw().merge_base(*patch.target, *patch.oid)?; + + let patch = patches + .create( + patch.title, + patch.description, + patch::MergeTarget::default(), + base_oid, + patch.oid, + &patch.tags, + &signer, + ) + .map_err(Error::from)?; + + Ok::<_, Error>(( + StatusCode::CREATED, + Json(json!({ "success": true, "id": patch.id.to_string() })), + )) +} + /// Get project patches list. /// `GET /projects/:project/patches` async fn patches_handler( @@ -531,7 +582,7 @@ async fn patches_handler( let per_page = per_page.unwrap_or(10); let storage = &ctx.profile.storage; let repo = storage.repository(project)?; - let patches = Patches::open(ctx.profile.public_key, &repo)?; + let patches = patch::Patches::open(ctx.profile.public_key, &repo)?; let mut patches = patches.all()?.filter_map(|r| r.ok()).collect::>(); patches.sort_by(|(_, a, _), (_, b, _)| b.timestamp().cmp(&a.timestamp())); let patches = patches @@ -552,7 +603,7 @@ async fn patch_handler( ) -> impl IntoResponse { let storage = &ctx.profile.storage; let repo = storage.repository(project)?; - let patch = Patches::open(ctx.profile.public_key, &repo)? + let patch = patch::Patches::open(ctx.profile.public_key, &repo)? .get(&patch_id.into())? .ok_or(Error::NotFound)?; @@ -566,7 +617,7 @@ mod routes { use pretty_assertions::assert_eq; use serde_json::json; - use crate::test::{self, get, patch, post, HEAD, HEAD_1, ISSUE_ID, PATCH_ID}; + use crate::test::{self, get, patch, post, HEAD, HEAD_1, ISSUE_ID, PATCH_ID, SESSION_ID}; const CREATED_ISSUE_ID: &str = "745052a1603000b9566445753d7e2fee1ff5041f"; @@ -1081,7 +1132,7 @@ mod routes { &app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues", Some(Body::from(body)), - Some("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy".to_string()), + Some(SESSION_ID.to_string()), ) .await; @@ -1144,7 +1195,7 @@ mod routes { &app, format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues/{ISSUE_ID}"), Some(Body::from(body)), - Some("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy".to_string()), + Some(SESSION_ID.to_string()), ) .await; @@ -1214,7 +1265,7 @@ mod routes { &app, format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues/{ISSUE_ID}"), Some(Body::from(body)), - Some("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy".to_string()), + Some(SESSION_ID.to_string()), ) .await; @@ -1328,4 +1379,69 @@ mod routes { ) ); } + + #[tokio::test] + async fn test_projects_create_patches() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test::seed(tmp.path()); + let app = super::router(ctx.to_owned()); + test::create_session(ctx).await; + let body = serde_json::to_vec(&json!({ + "title": "Update README", + "description": "Do some changes to README", + "target": HEAD, + "oid": HEAD_1, + "tags": [], + })) + .unwrap(); + let response = post( + &app, + "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/patches", + Some(Body::from(body)), + Some(SESSION_ID.to_string()), + ) + .await; + + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!( + response.json().await, + json!( + { + "success": true, + "id": "cd370c8a263e8b0c9836d6a5dd3bf7a633d69acf", + } + ) + ); + + let response = get( + &app, + "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/patches/cd370c8a263e8b0c9836d6a5dd3bf7a633d69acf", + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!( + { + "id": "cd370c8a263e8b0c9836d6a5dd3bf7a633d69acf", + "author": { + "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + }, + "title": "Update README", + "description": "Do some changes to README", + "state": "proposed", + "target": "delegates", + "tags": [], + "revisions": [ + { + "id": "a65512fc3b8ee7ec50f053c40266538fa11b82dd", + "description": "", + "reviews": [], + } + ], + } + ) + ); + } } diff --git a/radicle-httpd/src/test.rs b/radicle-httpd/src/test.rs index ce12a261..eec1e3b4 100644 --- a/radicle-httpd/src/test.rs +++ b/radicle-httpd/src/test.rs @@ -25,6 +25,7 @@ pub const HEAD: &str = "1e978d19f251cd9821d9d9a76d1bd436bf0690d5"; pub const HEAD_1: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7"; pub const PATCH_ID: &str = "afb3063f8f0343fa31d2a0d55bac2a6f4a77125e"; pub const ISSUE_ID: &str = "331569cd5e4dcc55104363ebce92c78b0e5d67d4"; +pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy"; const PASSWORD: &str = "radicle"; @@ -144,7 +145,7 @@ pub async fn create_session(ctx: Context) { let issued_at = OffsetDateTime::now_utc(); let mut sessions = ctx.sessions().write().await; sessions.insert( - String::from("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy"), + String::from(SESSION_ID), auth::Session { status: auth::AuthState::Authorized, public_key: ctx.profile().public_key,