From 4552c1f2966615de0de21bf8d88d06cd68f2302c Mon Sep 17 00:00:00 2001 From: xphoniex Date: Sat, 4 Feb 2023 13:22:09 +0000 Subject: [PATCH] httpd: Add routes for patch ops Signed-off-by: xphoniex --- radicle-httpd/src/api/json.rs | 21 ++++++ radicle-httpd/src/api/test.rs | 18 +++++ radicle-httpd/src/api/v1/projects.rs | 106 +++++++++++++++++++++++++++ radicle/src/cob/patch.rs | 22 ++++++ 4 files changed, 167 insertions(+) diff --git a/radicle-httpd/src/api/json.rs b/radicle-httpd/src/api/json.rs index 9516b4e9..9de6bf17 100644 --- a/radicle-httpd/src/api/json.rs +++ b/radicle-httpd/src/api/json.rs @@ -4,6 +4,7 @@ use std::path::Path; use serde_json::{json, Value}; +use radicle::cob::patch::{Patch, PatchId}; use radicle_surf::blob::Blob; use radicle_surf::tree::Tree; use radicle_surf::{Commit, Stats}; @@ -61,6 +62,26 @@ pub(crate) fn tree(tree: &Tree, path: &str, stats: &Stats) -> Value { }) } +/// Returns JSON for a `patch`. +pub(crate) fn patch(id: PatchId, patch: Patch) -> Value { + json!({ + "id": id.to_string(), + "author": patch.author(), + "title": patch.title(), + "description": patch.description(), + "state": patch.state(), + "target": patch.target(), + "tags": patch.tags().collect::>(), + "revisions": patch.revisions().map(|(id, rev)| { + json!({ + "id": id, + "description": rev.description(), + "reviews": rev.reviews().collect::>(), + }) + }).collect::>(), + }) +} + /// Returns the name part of a path string. fn name_in_path(path: &str) -> &str { match path.rsplit('/').next() { diff --git a/radicle-httpd/src/api/test.rs b/radicle-httpd/src/api/test.rs index 5331c5b7..92ca2f92 100644 --- a/radicle-httpd/src/api/test.rs +++ b/radicle-httpd/src/api/test.rs @@ -1,4 +1,5 @@ use std::path::Path; +use std::str::FromStr; use std::sync::Arc; use std::{env, fs}; @@ -9,6 +10,7 @@ use serde_json::Value; use tower::ServiceExt; use radicle::cob::issue::Issues; +use radicle::cob::patch::{MergeTarget, Patches}; use radicle::git::raw as git2; use radicle::storage::WriteStorage; use radicle_cli::commands::rad_init; @@ -110,6 +112,22 @@ pub fn seed(dir: &Path) -> Context { ) .unwrap(); + // eq. rad patch open + let mut patches = Patches::open(*signer.public_key(), &repo).unwrap(); + let oid = radicle::git::Oid::from_str(HEAD).unwrap(); + let base = radicle::git::Oid::from_str(HEAD_1).unwrap(); + patches + .create( + "A new `hello word`", + "change `hello world` in README to something else", + MergeTarget::Delegates, + base, + oid, + &[], + &signer, + ) + .unwrap(); + Context { profile: Arc::new(profile), sessions: Default::default(), diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index 9eb2385d..dc7afc3c 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -12,6 +12,7 @@ use serde_json::json; use tower_http::set_header::SetResponseHeaderLayer; use radicle::cob::issue::Issues; +use radicle::cob::patch::Patches; use radicle::cob::thread::{self, CommentId}; use radicle::cob::Timestamp; use radicle::identity::{Id, PublicKey}; @@ -49,6 +50,8 @@ pub fn router(ctx: Context) -> Router { .route("/projects/:project/readme/:sha", get(readme_handler)) .route("/projects/:project/issues", get(issues_handler)) .route("/projects/:project/issues/:id", get(issue_handler)) + .route("/projects/:project/patches", get(patches_handler)) + .route("/projects/:project/patches/:id", get(patch_handler)) .with_state(ctx) } @@ -422,6 +425,46 @@ async fn issue_handler( Ok::<_, Error>(Json(issue)) } +/// Get project patches list. +/// `GET /projects/:project/patches` +async fn patches_handler( + State(ctx): State, + Path(project): Path, + Query(qs): Query, +) -> impl IntoResponse { + let PaginationQuery { page, per_page } = qs; + let page = page.unwrap_or(0); + 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 = patches + .all()? + .into_iter() + .filter_map(|r| r.ok()) + .map(|(id, patch, _)| api::json::patch(id, patch)) + .skip(page * per_page) + .take(per_page) + .collect::>(); + + Ok::<_, Error>(Json(patches)) +} + +/// Get project patch. +/// `GET /projects/:project/patches/:id` +async fn patch_handler( + State(ctx): State, + Path((project, patch_id)): Path<(Id, Oid)>, +) -> impl IntoResponse { + let storage = &ctx.profile.storage; + let repo = storage.repository(project)?; + let patch = Patches::open(ctx.profile.public_key, &repo)? + .get(&patch_id.into())? + .ok_or(Error::NotFound)?; + + Ok::<_, Error>(Json(api::json::patch(patch_id.into(), patch))) +} + #[derive(Serialize)] struct Author { id: PublicKey, @@ -939,4 +982,67 @@ 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, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/patches").await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!([ + { + "id": "5de9f17ca5326258412ab02f9a5339b6482198ce", + "author": { + "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + }, + "title": "A new `hello word`", + "description": "change `hello world` in README to something else", + "state": "proposed", + "target": "delegates", + "tags": [], + "revisions": [ + { + "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1", + "description": "", + "reviews": [], + } + ], + } + ]) + ); + + let response = get( + &app, + "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/patches/5de9f17ca5326258412ab02f9a5339b6482198ce", + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.json().await, + json!( + { + "id": "5de9f17ca5326258412ab02f9a5339b6482198ce", + "author": { + "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" + }, + "title": "A new `hello word`", + "description": "change `hello world` in README to something else", + "state": "proposed", + "target": "delegates", + "tags": [], + "revisions": [ + { + "id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1", + "description": "", + "reviews": [], + } + ], + } + ) + ); + } } diff --git a/radicle/src/cob/patch.rs b/radicle/src/cob/patch.rs index 0cedcc95..0af441fd 100644 --- a/radicle/src/cob/patch.rs +++ b/radicle/src/cob/patch.rs @@ -5,6 +5,7 @@ use std::ops::Range; use std::str::FromStr; use once_cell::sync::Lazy; +use serde::ser::SerializeStruct; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -180,6 +181,10 @@ impl Patch { .timestamp } + pub fn tags(&self) -> impl Iterator { + self.tags.iter() + } + pub fn description(&self) -> Option<&str> { Some(self.description.get().get()) } @@ -375,6 +380,10 @@ impl Revision { pub fn description(&self) -> &str { self.description.get() } + + pub fn reviews(&self) -> impl DoubleEndedIterator { + self.reviews.iter() + } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -479,6 +488,19 @@ pub struct Review { pub timestamp: Max, } +impl Serialize for Review { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let mut state = serializer.serialize_struct("Review", 3)?; + state.serialize_field("verdict", &self.verdict())?; + state.serialize_field("comment", &self.comment())?; + state.serialize_field("timestamp", &self.timestamp())?; + state.end() + } +} + impl Semilattice for Review { fn merge(&mut self, other: Self) { self.verdict.merge(other.verdict);