From af3ea6a11a99543046a5a4396d48743cf8c33cac Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Thu, 19 Jan 2023 10:50:08 +0100 Subject: [PATCH] httpd: Add issue endpoints Signed-off-by: Sebastian Martinez --- Cargo.lock | 13 ++++ radicle-httpd/Cargo.toml | 1 + radicle-httpd/src/api/error.rs | 4 ++ radicle-httpd/src/api/json.rs | 1 + radicle-httpd/src/api/v1/projects.rs | 103 +++++++++++++++++++++++++-- radicle/src/cob/issue.rs | 23 ++++++ 6 files changed, 141 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7eb7bcad..c67c669d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,18 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-auth" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "620b37645b77baab8160f93421568d7b3dd25da0a160fab38eb1c4ef611f6d98" +dependencies = [ + "async-trait", + "axum-core", + "base64", + "http", +] + [[package]] name = "axum-core" version = "0.3.2" @@ -1938,6 +1950,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "axum-auth", "axum-server", "chrono", "fastrand", diff --git a/radicle-httpd/Cargo.toml b/radicle-httpd/Cargo.toml index a2b53608..1654d6bc 100644 --- a/radicle-httpd/Cargo.toml +++ b/radicle-httpd/Cargo.toml @@ -17,6 +17,7 @@ logfmt = [ [dependencies] anyhow = { version = "1" } axum = { version = "0.6.2", default-features = false, features = ["headers", "json", "query", "tokio"] } +axum-auth = { version= "0.4.0", default-features = false, features = ["auth-bearer"] } axum-server = { version = "0.4.4", default-features = false } chrono = { version = "0.4.22" } fastrand = { version = "1.7.0" } diff --git a/radicle-httpd/src/api/error.rs b/radicle-httpd/src/api/error.rs index 7995f5a6..b6b48d7d 100644 --- a/radicle-httpd/src/api/error.rs +++ b/radicle-httpd/src/api/error.rs @@ -30,6 +30,10 @@ pub enum Error { #[error(transparent)] Storage(#[from] radicle::storage::Error), + /// Cob issue error. + #[error(transparent)] + CobIssue(#[from] radicle::cob::issue::Error), + /// Cob store error. #[error(transparent)] CobStore(#[from] radicle::cob::store::Error), diff --git a/radicle-httpd/src/api/json.rs b/radicle-httpd/src/api/json.rs index fdc6b55d..1f6530b6 100644 --- a/radicle-httpd/src/api/json.rs +++ b/radicle-httpd/src/api/json.rs @@ -85,6 +85,7 @@ pub(crate) fn issue(id: IssueId, issue: Issue) -> Value { json!({ "id": id.to_string(), "author": issue.author(), + "assignees": issue.assigned().collect::>(), "title": issue.title(), "state": issue.state(), "discussion": issue.comments().collect::(), diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index 0a21a8da..13ee94ca 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -4,15 +4,17 @@ use axum::extract::State; use axum::handler::Handler; use axum::http::{header, HeaderValue}; use axum::response::IntoResponse; -use axum::routing::get; +use axum::routing::{get, patch, post}; use axum::{Json, Router}; +use axum_auth::AuthBearer; use hyper::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::json; use tower_http::set_header::SetResponseHeaderLayer; -use radicle::cob::issue::Issues; +use radicle::cob::issue::{Action, Issues}; use radicle::cob::patch::Patches; +use radicle::cob::{thread, Tag}; use radicle::identity::Id; use radicle::node::NodeId; use radicle::storage::{git::paths, ReadRepository, WriteStorage}; @@ -46,8 +48,14 @@ pub fn router(ctx: Context) -> Router { .route("/projects/:project/remotes/:peer", get(remote_handler)) .route("/projects/:project/blob/:sha/*path", get(blob_handler)) .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/issues", + post(issue_create_handler).get(issues_handler), + ) + .route( + "/projects/:project/issues/:id", + patch(issue_update_handler).get(issue_handler), + ) .route("/projects/:project/patches", get(patches_handler)) .route("/projects/:project/patches/:id", get(patch_handler)) .with_state(ctx) @@ -391,6 +399,92 @@ async fn issues_handler( Ok::<_, Error>(Json(issues)) } +#[derive(Debug, Deserialize, Serialize)] +pub struct IssueCreate { + pub title: String, + pub description: String, + pub tags: Vec, +} + +/// Create a new issue. +/// `POST /projects/:project/issues` +async fn issue_create_handler( + State(ctx): State, + AuthBearer(token): AuthBearer, + Path(project): Path, + Json(issue): Json, +) -> impl IntoResponse { + let sessions = ctx.sessions.read().await; + sessions.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 issues = Issues::open(ctx.profile.public_key, &repo)?; + issues + .create(issue.title, issue.description, &issue.tags, &signer) + .map_err(Error::from)?; + + Ok::<_, Error>((StatusCode::CREATED, Json(json!({ "success": true })))) +} + +/// Update an issue. +/// `PATCH /projects/:project/issues/:id` +async fn issue_update_handler( + State(ctx): State, + AuthBearer(token): AuthBearer, + Path((project, issue_id)): Path<(Id, Oid)>, + Json(action): Json, +) -> impl IntoResponse { + let sessions = ctx.sessions.write().await; + sessions.get(&token).ok_or(Error::Auth("Unauthorized"))?; + 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 issue = issues.get_mut(&issue_id.into())?; + match action { + Action::Assign { add, remove } => { + issue.assign(add, &signer)?; + issue.unassign(remove, &signer)?; + } + Action::Lifecycle { state } => { + issue.lifecycle(state, &signer)?; + } + Action::Tag { add, remove } => { + issue.tag(add, remove, &signer)?; + } + Action::Edit { title } => { + issue.edit(title, &signer)?; + } + Action::Thread { action } => { + let mut actor = thread::Actor::new(ctx.profile.signer().unwrap()); + match action { + thread::Action::Comment { body, reply_to } => { + if let Some(reply_to) = reply_to { + issue.comment(body, reply_to, &signer)?; + } else { + issue.thread(body, &signer)?; + } + } + thread::Action::React { to, reaction, .. } => { + issue.react(to, reaction, &signer)?; + } + thread::Action::Edit { id, body } => { + actor.edit(id, &body); + } + thread::Action::Redact { id } => { + actor.redact(id); + } + } + } + }; + + Ok::<_, Error>(Json(json!({ "success": true }))) +} + /// Get project issue. /// `GET /projects/:project/issues/:id` async fn issue_handler( @@ -909,6 +1003,7 @@ mod routes { "state": { "status": "open" }, + "assignees": [], "discussion": [ { "author": { diff --git a/radicle/src/cob/issue.rs b/radicle/src/cob/issue.rs index e46155ca..74cda08d 100644 --- a/radicle/src/cob/issue.rs +++ b/radicle/src/cob/issue.rs @@ -274,6 +274,11 @@ impl<'a, 'g> IssueMut<'a, 'g> { self.transaction("Assign", signer, |tx| tx.assign(assignees, vec![])) } + /// Set the issue title. + pub fn edit(&mut self, title: impl ToString, signer: &G) -> Result { + self.transaction("Edit", signer, |tx| tx.edit(title)) + } + /// Lifecycle an issue. pub fn lifecycle(&mut self, state: State, signer: &G) -> Result { self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state)) @@ -598,6 +603,24 @@ mod test { assert!(assignees.contains(&assignee_two)); } + #[test] + fn test_issue_edit_title() { + let tmp = tempfile::tempdir().unwrap(); + let (_, signer, project) = test::setup::context(&tmp); + let mut issues = Issues::open(*signer.public_key(), &project).unwrap(); + let mut issue = issues + .create("My first issue", "Blah blah blah.", &[], &signer) + .unwrap(); + + issue.edit("Sorry typo", &signer).unwrap(); + + let id = issue.id; + let issue = issues.get(&id).unwrap().unwrap(); + let r = issue.title(); + + assert_eq!(r, "Sorry typo"); + } + #[test] fn test_issue_react() { let tmp = tempfile::tempdir().unwrap();